From eaea4aa6d38cf1d82477180b973d49195d5251f2 Mon Sep 17 00:00:00 2001 From: Michal Bajer Date: Thu, 11 Jul 2024 18:22:32 +0200 Subject: [PATCH] feat(ledger-browser): implement dynamic app setup - Read gui supabase connection information from environment variable. Include `.env` files in common `.gitignore` file. Change ledger-browser typescript target and module to `esnext` to use vite environment variables. - Read app configuration from the supabase DB. - Add button on home page for adding new application. Clicking on it will open dialog with setup wizzard. User must filter apps by it's group (step 1), select the app (step 2), input common app configuration data (step 3) and lastly input app-specific configuration (JSON format). - Add button to configure already added app. It opens a dialog that allows editing app details in the database. It also contains a button for deleting the app (after confirmation). - Show full screen error message with setup guidelines when app has failed to connect to supabase. - Clean up supabase type files, move app-related typedefs to specific app dirs. Depends on #3347 Signed-off-by: Michal Bajer --- .gitignore | 1 + packages/cacti-ledger-browser/.env.template | 3 + .../main/typescript/CactiLedgerBrowserApp.tsx | 76 +++-- .../AccountERC20View/AccountERC20View.tsx | 2 +- .../ERC20BalanceHistoryTable.tsx | 2 +- .../AccountERC20View/ERC20TokenDetails.tsx | 2 +- .../AccountERC20View/ERC20TokenList.tsx | 2 +- .../AccountERC20View/balanceHistory.ts | 2 +- .../src/main/typescript/apps/eth/hooks.tsx | 16 ++ .../src/main/typescript/apps/eth/index.tsx | 113 +++++--- .../src/main/typescript/apps/eth/queries.ts | 35 ++- .../typescript/apps/eth/supabase-types.ts | 45 +++ .../CertificateDetailsBox.tsx | 2 +- .../src/main/typescript/apps/fabric/hooks.tsx | 16 ++ .../src/main/typescript/apps/fabric/index.tsx | 122 +++++--- .../TransactionDetails/TranactionInfoCard.tsx | 2 +- .../TransactionActionsTable.tsx | 2 +- .../main/typescript/apps/fabric/queries.ts | 32 ++- ...ic-supabase-types.ts => supabase-types.ts} | 0 .../main/typescript/common/app-category.tsx | 34 +++ .../src/main/typescript/common/config.tsx | 13 +- .../typescript/common/createApplications.tsx | 29 ++ .../src/main/typescript/common/queries.ts | 151 +++++++++- .../typescript/common/supabase-client.tsx | 69 ----- .../main/typescript/common/supabase-types.ts | 122 +------- .../src/main/typescript/common/types/app.ts | 42 ++- .../AppSetupForms/AppOptionsForm.tsx | 46 +++ .../components/AppSetupForms/AppSetupForm.tsx | 92 ++++++ .../ConnectionFailedDialog.tsx | 79 ++++++ .../components/Layout/HeaderBar.tsx | 4 +- .../src/main/typescript/main.tsx | 3 +- .../pages/add-new-app/AddNewApp.tsx | 169 +++++++++++ .../add-new-app/AppSpecificSetupView.tsx | 72 +++++ .../pages/add-new-app/CommonSetupView.tsx | 55 ++++ .../pages/add-new-app/SelectAppView.tsx | 59 ++++ .../pages/add-new-app/SelectGroupView.tsx | 52 ++++ .../pages/configure-app/ConfigureApp.tsx | 267 ++++++++++++++++++ .../pages/home/AddApplicationPopupCard.tsx | 95 +++++++ .../main/typescript/pages/home/AppCard.tsx | 44 ++- .../main/typescript/pages/home/HomePage.tsx | 19 +- .../src/main/typescript/vite-env.d.ts | 10 + packages/cacti-ledger-browser/tsconfig.json | 2 + 42 files changed, 1646 insertions(+), 357 deletions(-) create mode 100644 packages/cacti-ledger-browser/.env.template create mode 100644 packages/cacti-ledger-browser/src/main/typescript/apps/eth/hooks.tsx create mode 100644 packages/cacti-ledger-browser/src/main/typescript/apps/eth/supabase-types.ts create mode 100644 packages/cacti-ledger-browser/src/main/typescript/apps/fabric/hooks.tsx rename packages/cacti-ledger-browser/src/main/typescript/apps/fabric/{fabric-supabase-types.ts => supabase-types.ts} (100%) create mode 100644 packages/cacti-ledger-browser/src/main/typescript/common/app-category.tsx create mode 100644 packages/cacti-ledger-browser/src/main/typescript/common/createApplications.tsx delete mode 100644 packages/cacti-ledger-browser/src/main/typescript/common/supabase-client.tsx create mode 100644 packages/cacti-ledger-browser/src/main/typescript/components/AppSetupForms/AppOptionsForm.tsx create mode 100644 packages/cacti-ledger-browser/src/main/typescript/components/AppSetupForms/AppSetupForm.tsx create mode 100644 packages/cacti-ledger-browser/src/main/typescript/components/ConnectionFailedDialog/ConnectionFailedDialog.tsx create mode 100644 packages/cacti-ledger-browser/src/main/typescript/pages/add-new-app/AddNewApp.tsx create mode 100644 packages/cacti-ledger-browser/src/main/typescript/pages/add-new-app/AppSpecificSetupView.tsx create mode 100644 packages/cacti-ledger-browser/src/main/typescript/pages/add-new-app/CommonSetupView.tsx create mode 100644 packages/cacti-ledger-browser/src/main/typescript/pages/add-new-app/SelectAppView.tsx create mode 100644 packages/cacti-ledger-browser/src/main/typescript/pages/add-new-app/SelectGroupView.tsx create mode 100644 packages/cacti-ledger-browser/src/main/typescript/pages/configure-app/ConfigureApp.tsx create mode 100644 packages/cacti-ledger-browser/src/main/typescript/pages/home/AddApplicationPopupCard.tsx diff --git a/.gitignore b/.gitignore index 1f691faf22..095fe5d187 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,7 @@ cactus-openapi-spec-*.json build/ .gradle/ site/ +.env .build-cache/*.tsbuildinfo diff --git a/packages/cacti-ledger-browser/.env.template b/packages/cacti-ledger-browser/.env.template new file mode 100644 index 0000000000..03f2a4e0f1 --- /dev/null +++ b/packages/cacti-ledger-browser/.env.template @@ -0,0 +1,3 @@ +VITE_SUPABASE_URL=__SUPABSE_URL__ +VITE_SUPABASE_KEY=__SUPABASE_KEY__ +VITE_SUPABASE_SCHEMA=public \ No newline at end of file diff --git a/packages/cacti-ledger-browser/src/main/typescript/CactiLedgerBrowserApp.tsx b/packages/cacti-ledger-browser/src/main/typescript/CactiLedgerBrowserApp.tsx index 12e43e24dd..2be409ec8e 100644 --- a/packages/cacti-ledger-browser/src/main/typescript/CactiLedgerBrowserApp.tsx +++ b/packages/cacti-ledger-browser/src/main/typescript/CactiLedgerBrowserApp.tsx @@ -1,28 +1,37 @@ -import { useRoutes, BrowserRouter, RouteObject } from "react-router-dom"; +import { + useRoutes, + BrowserRouter, + RouteObject, + Outlet, +} from "react-router-dom"; import CssBaseline from "@mui/material/CssBaseline"; +import CircularProgress from "@mui/material/CircularProgress"; import { ThemeProvider, createTheme } from "@mui/material/styles"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + QueryClient, + QueryClientProvider, + useQuery, +} from "@tanstack/react-query"; // import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { themeOptions } from "./theme"; import ContentLayout from "./components/Layout/ContentLayout"; import HeaderBar from "./components/Layout/HeaderBar"; import HomePage from "./pages/home/HomePage"; -import { AppConfig, AppListEntry } from "./common/types/app"; +import { AppInstance, AppListEntry } from "./common/types/app"; import { patchAppRoutePath } from "./common/utils"; import { NotificationProvider } from "./common/context/NotificationContext"; - -type AppConfigProps = { - appConfig: AppConfig[]; -}; +import { guiAppConfig } from "./common/queries"; +import createApplications from "./common/createApplications"; +import ConnectionFailedDialog from "./components/ConnectionFailedDialog/ConnectionFailedDialog"; /** * Get list of all apps from the config */ -function getAppList(appConfig: AppConfig[]) { +function getAppList(appConfig: AppInstance[]) { const appList: AppListEntry[] = appConfig.map((app) => { return { - path: app.options.path, + path: app.path, name: app.appName, }; }); @@ -38,17 +47,17 @@ function getAppList(appConfig: AppConfig[]) { /** * Create header bar for each app based on app menuEntries field in config. */ -function getHeaderBarRoutes(appConfig: AppConfig[]) { +function getHeaderBarRoutes(appConfig: AppInstance[]) { const appList = getAppList(appConfig); const headerRoutesConfig = appConfig.map((app) => { return { - key: app.options.path, - path: `${app.options.path}/*`, + key: app.path, + path: `${app.path}/*`, element: ( ), @@ -65,15 +74,16 @@ function getHeaderBarRoutes(appConfig: AppConfig[]) { /** * Create content routes */ -function getContentRoutes(appConfig: AppConfig[]) { +function getContentRoutes(appConfig: AppInstance[]) { const appRoutes: RouteObject[] = appConfig.map((app) => { return { - key: app.options.path, - path: app.options.path, + key: app.path, + path: app.path, + element: , children: app.routes.map((route) => { return { key: route.path, - path: patchAppRoutePath(app.options.path, route.path), + path: patchAppRoutePath(app.path, route.path), element: route.element, children: route.children, }; @@ -84,7 +94,7 @@ function getContentRoutes(appConfig: AppConfig[]) { // Include landing / welcome page appRoutes.push({ index: true, - element: , + element: , }); return useRoutes([ @@ -96,17 +106,35 @@ function getContentRoutes(appConfig: AppConfig[]) { ]); } -const App: React.FC = ({ appConfig }) => { +function App() { + const { isError, isPending, data } = useQuery(guiAppConfig()); + + if (isError) { + return ; + } + + const appConfig = createApplications(data); + const headerRoutes = getHeaderBarRoutes(appConfig); const contentRoutes = getContentRoutes(appConfig); return (
+ {isPending && ( + + )} {headerRoutes} {contentRoutes}
); -}; +} // MUI Theme const theme = createTheme(themeOptions); @@ -114,20 +142,18 @@ const theme = createTheme(themeOptions); // React Query client const queryClient = new QueryClient(); -const CactiLedgerBrowserApp: React.FC = ({ appConfig }) => { +export default function CactiLedgerBrowserApp() { return ( - + {/* */} ); -}; - -export default CactiLedgerBrowserApp; +} diff --git a/packages/cacti-ledger-browser/src/main/typescript/apps/eth/components/AccountERC20View/AccountERC20View.tsx b/packages/cacti-ledger-browser/src/main/typescript/apps/eth/components/AccountERC20View/AccountERC20View.tsx index 5d052afcd8..8d7eb3ff6d 100644 --- a/packages/cacti-ledger-browser/src/main/typescript/apps/eth/components/AccountERC20View/AccountERC20View.tsx +++ b/packages/cacti-ledger-browser/src/main/typescript/apps/eth/components/AccountERC20View/AccountERC20View.tsx @@ -3,9 +3,9 @@ import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; import Divider from "@mui/material/Divider"; -import { TokenERC20 } from "../../../../common/supabase-types"; import ERC20TokenList from "./ERC20TokenList"; import ERC20TokenDetails from "./ERC20TokenDetails"; +import { TokenERC20 } from "../../supabase-types"; export type AccountERC20ViewProps = { accountAddress: string; diff --git a/packages/cacti-ledger-browser/src/main/typescript/apps/eth/components/AccountERC20View/ERC20BalanceHistoryTable.tsx b/packages/cacti-ledger-browser/src/main/typescript/apps/eth/components/AccountERC20View/ERC20BalanceHistoryTable.tsx index 730c5afd41..fc935b9e0a 100644 --- a/packages/cacti-ledger-browser/src/main/typescript/apps/eth/components/AccountERC20View/ERC20BalanceHistoryTable.tsx +++ b/packages/cacti-ledger-browser/src/main/typescript/apps/eth/components/AccountERC20View/ERC20BalanceHistoryTable.tsx @@ -14,8 +14,8 @@ import Typography from "@mui/material/Typography"; import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown"; import ArrowDropUpIcon from "@mui/icons-material/ArrowDropUp"; -import { TokenHistoryItem20 } from "../../../../common/supabase-types"; import ShortenedTypography from "../../../../components/ui/ShortenedTypography"; +import { TokenHistoryItem20 } from "../../supabase-types"; const StyledHeaderCell = styled(TableCell)(({ theme }) => ({ color: theme.palette.primary.main, diff --git a/packages/cacti-ledger-browser/src/main/typescript/apps/eth/components/AccountERC20View/ERC20TokenDetails.tsx b/packages/cacti-ledger-browser/src/main/typescript/apps/eth/components/AccountERC20View/ERC20TokenDetails.tsx index 184cf3ba82..e1283f5d4d 100644 --- a/packages/cacti-ledger-browser/src/main/typescript/apps/eth/components/AccountERC20View/ERC20TokenDetails.tsx +++ b/packages/cacti-ledger-browser/src/main/typescript/apps/eth/components/AccountERC20View/ERC20TokenDetails.tsx @@ -7,12 +7,12 @@ import Typography from "@mui/material/Typography"; import Skeleton from "@mui/material/Skeleton"; import { ethERC20TokenHistory } from "../../queries"; -import { TokenERC20 } from "../../../../common/supabase-types"; import ShortenedTypography from "../../../../components/ui/ShortenedTypography"; import { useNotification } from "../../../../common/context/NotificationContext"; import ERC20BalanceHistoryChart from "./ERC20BalanceHistoryChart"; import ERC20BalanceHistoryTable from "./ERC20BalanceHistoryTable"; import { createBalanceHistoryList } from "./balanceHistory"; +import { TokenERC20 } from "../../supabase-types"; function TokenDetailsPlaceholder() { return ( diff --git a/packages/cacti-ledger-browser/src/main/typescript/apps/eth/components/AccountERC20View/ERC20TokenList.tsx b/packages/cacti-ledger-browser/src/main/typescript/apps/eth/components/AccountERC20View/ERC20TokenList.tsx index 50208a7563..e872e59c8b 100644 --- a/packages/cacti-ledger-browser/src/main/typescript/apps/eth/components/AccountERC20View/ERC20TokenList.tsx +++ b/packages/cacti-ledger-browser/src/main/typescript/apps/eth/components/AccountERC20View/ERC20TokenList.tsx @@ -14,8 +14,8 @@ import TableHead from "@mui/material/TableHead"; import CircularProgress from "@mui/material/CircularProgress"; import { useNotification } from "../../../../common/context/NotificationContext"; -import { TokenERC20 } from "../../../../common/supabase-types"; import { ethAllERC20TokensByAccount } from "../../queries"; +import { TokenERC20 } from "../../supabase-types"; const StyledHeaderCell = styled(TableCell)(({ theme }) => ({ color: theme.palette.primary.main, diff --git a/packages/cacti-ledger-browser/src/main/typescript/apps/eth/components/AccountERC20View/balanceHistory.ts b/packages/cacti-ledger-browser/src/main/typescript/apps/eth/components/AccountERC20View/balanceHistory.ts index 36e62f745a..c48a4f7abe 100644 --- a/packages/cacti-ledger-browser/src/main/typescript/apps/eth/components/AccountERC20View/balanceHistory.ts +++ b/packages/cacti-ledger-browser/src/main/typescript/apps/eth/components/AccountERC20View/balanceHistory.ts @@ -1,4 +1,4 @@ -import { TokenHistoryItem20 } from "../../../../common/supabase-types"; +import { TokenHistoryItem20 } from "../../supabase-types"; export type BalanceHistoryListData = { created_at: string; diff --git a/packages/cacti-ledger-browser/src/main/typescript/apps/eth/hooks.tsx b/packages/cacti-ledger-browser/src/main/typescript/apps/eth/hooks.tsx new file mode 100644 index 0000000000..fb6ffde86d --- /dev/null +++ b/packages/cacti-ledger-browser/src/main/typescript/apps/eth/hooks.tsx @@ -0,0 +1,16 @@ +import { useOutletContext } from "react-router-dom"; +import { AppInstancePersistencePluginOptions } from "../../common/types/app"; + +export function useEthAppConfig() { + return useOutletContext(); +} + +export function useEthSupabaseConfig() { + const appConfig = useEthAppConfig(); + + return { + schema: appConfig.supabaseSchema, + url: appConfig.supabaseUrl, + key: appConfig.supabaseKey, + }; +} diff --git a/packages/cacti-ledger-browser/src/main/typescript/apps/eth/index.tsx b/packages/cacti-ledger-browser/src/main/typescript/apps/eth/index.tsx index 9ee731b528..78fd712e40 100644 --- a/packages/cacti-ledger-browser/src/main/typescript/apps/eth/index.tsx +++ b/packages/cacti-ledger-browser/src/main/typescript/apps/eth/index.tsx @@ -2,49 +2,84 @@ import Dashboard from "./pages/Dashboard/Dashboard"; import Blocks from "./pages/Blocks/Blocks"; import Transactions from "./pages/Transactions/Transactions"; import Accounts from "./pages/Accounts/Accounts"; -import { AppConfig } from "../../common/types/app"; +import { + AppInstancePersistencePluginOptions, + AppDefinition, +} from "../../common/types/app"; import { usePersistenceAppStatus } from "../../common/hook/use-persistence-app-status"; import PersistencePluginStatus from "../../components/PersistencePluginStatus/PersistencePluginStatus"; +import { GuiAppConfig } from "../../common/supabase-types"; +import { AppCategory } from "../../common/app-category"; -const ethConfig: AppConfig = { +const ethBrowserAppDefinition: AppDefinition = { appName: "Ethereum Browser", - options: { - instanceName: "Ethereum", - description: - "Applicaion for browsing Ethereum ledger blocks, transactions and tokens. Requires Ethereum persistence plugin to work correctly.", - path: "/eth", + category: AppCategory.LedgerBrowser, + defaultInstanceName: "My Eth Browser", + defaultDescription: + "Application for browsing Ethereum ledger blocks, transactions and tokens. Requires Ethereum persistence plugin to work correctly.", + defaultPath: "/eth", + defaultOptions: { + supabaseUrl: "http://localhost:8000", + supabaseKey: + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE", + supabaseSchema: "ethereum", + }, + + createAppInstance(app: GuiAppConfig) { + const supabaseOptions = + app.options as any as AppInstancePersistencePluginOptions; + + if ( + !supabaseOptions || + !supabaseOptions.supabaseUrl || + !supabaseOptions.supabaseKey || + !supabaseOptions.supabaseSchema + ) { + throw new Error( + `Invalid ethereum app specific options in the database: ${JSON.stringify(supabaseOptions)}`, + ); + } + + return { + id: app.id, + appName: "Ethereum Browser", + instanceName: app.instance_name, + description: app.description, + path: app.path, + options: supabaseOptions, + menuEntries: [ + { + title: "Dashboard", + url: "/", + }, + { + title: "Accounts", + url: "/accounts", + }, + ], + routes: [ + { + element: , + }, + { + path: "blocks", + element: , + }, + { + path: "transactions", + element: , + }, + { + path: "accounts", + element: , + }, + ], + useAppStatus: () => usePersistenceAppStatus("PluginPersistenceEthereum"), + StatusComponent: ( + + ), + }; }, - menuEntries: [ - { - title: "Dashboard", - url: "/", - }, - { - title: "Accounts", - url: "/accounts", - }, - ], - routes: [ - { - element: , - }, - { - path: "blocks", - element: , - }, - { - path: "transactions", - element: , - }, - { - path: "accounts", - element: , - }, - ], - useAppStatus: () => usePersistenceAppStatus("PluginPersistenceEthereum"), - StatusComponent: ( - - ), }; -export default ethConfig; +export default ethBrowserAppDefinition; diff --git a/packages/cacti-ledger-browser/src/main/typescript/apps/eth/queries.ts b/packages/cacti-ledger-browser/src/main/typescript/apps/eth/queries.ts index 0d2903c275..aee3f48cd9 100644 --- a/packages/cacti-ledger-browser/src/main/typescript/apps/eth/queries.ts +++ b/packages/cacti-ledger-browser/src/main/typescript/apps/eth/queries.ts @@ -3,7 +3,7 @@ * @todo Move to separate directory if this file becomes too complex. */ -import { createClient } from "@supabase/supabase-js"; +import { SupabaseClient, createClient } from "@supabase/supabase-js"; import { queryOptions } from "@tanstack/react-query"; import { Transaction, @@ -11,17 +11,10 @@ import { TokenHistoryItem20, TokenMetadata721, TokenERC20, -} from "../../common/supabase-types"; +} from "./supabase-types"; +import { useEthSupabaseConfig } from "./hooks"; -// TODO - Configure for an app -const supabaseQueryKey = "supabase:ethereum"; -const supabaseUrl = "http://localhost:8000"; -const supabaseKey = - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE"; - -export const supabase = createClient(supabaseUrl, supabaseKey, { - schema: "ethereum", -}); +let supabase: SupabaseClient | undefined; function createQueryKey( tableName: string, @@ -30,12 +23,25 @@ function createQueryKey( return [tableName, { pagination }]; } +function useSupabaseClient(): [SupabaseClient, string] { + const supabaseConfig = useEthSupabaseConfig(); + + if (!supabase) { + supabase = createClient(supabaseConfig.url, supabaseConfig.key, { + schema: supabaseConfig.schema, + }); + } + + return [supabase, `supabase:${supabaseConfig.schema}`]; +} + /** * Get all recorded ethereum transactions. * Returns `queryOptions` to be used as argument to `useQuery` from `react-query`. * Supports paging. */ export function ethAllTransactionsQuery(page: number, pageSize: number) { + const [supabase, supabaseQueryKey] = useSupabaseClient(); const fromIndex = page * pageSize; const toIndex = fromIndex + pageSize - 1; const tableName = "transaction"; @@ -71,6 +77,7 @@ export function ethAccountTransactionsQuery( pageSize: number, accountAddress: string, ) { + const [supabase, supabaseQueryKey] = useSupabaseClient(); const fromIndex = page * pageSize; const toIndex = fromIndex + pageSize - 1; const tableName = "transaction"; @@ -102,6 +109,7 @@ export function ethAccountTransactionsQuery( * Supports paging. */ export function ethAllBlocksQuery(page: number, pageSize: number) { + const [supabase, supabaseQueryKey] = useSupabaseClient(); const fromIndex = page * pageSize; const toIndex = fromIndex + pageSize - 1; const tableName = "block"; @@ -134,6 +142,7 @@ export function ethERC20TokenHistory( tokenAddress: string, accountAddress: string, ) { + const [supabase, supabaseQueryKey] = useSupabaseClient(); const tableName = "erc20_token_history_view"; return queryOptions({ queryKey: [supabaseQueryKey, tableName, tokenAddress, accountAddress], @@ -167,6 +176,8 @@ export interface EthAllERC721TokensByAccountResponseType { * Returns `queryOptions` to be used as argument to `useQuery` from `react-query`. */ export function ethAllERC721TokensByAccount(accountAddress: string) { + const [supabase, supabaseQueryKey] = useSupabaseClient(); + return queryOptions({ queryKey: [supabaseQueryKey, "ethAllERC721TokensByAccount", accountAddress], queryFn: async () => { @@ -193,6 +204,8 @@ export function ethAllERC721TokensByAccount(accountAddress: string) { * Returns `queryOptions` to be used as argument to `useQuery` from `react-query`. */ export function ethAllERC20TokensByAccount(accountAddress: string) { + const [supabase, supabaseQueryKey] = useSupabaseClient(); + return queryOptions({ queryKey: [supabaseQueryKey, "ethAllERC20TokensByAccount", accountAddress], queryFn: async () => { diff --git a/packages/cacti-ledger-browser/src/main/typescript/apps/eth/supabase-types.ts b/packages/cacti-ledger-browser/src/main/typescript/apps/eth/supabase-types.ts new file mode 100644 index 0000000000..b2172fcd6d --- /dev/null +++ b/packages/cacti-ledger-browser/src/main/typescript/apps/eth/supabase-types.ts @@ -0,0 +1,45 @@ +export interface Transaction { + index: number; + hash: string; + block_number: number; + from: string; + to: string; + eth_value: number; + method_signature: string; + method_name: string; + id: string; +} + +export interface Block { + number: number; + created_at: string; + hash: string; + number_of_tx: number; + sync_at: string; +} + +export interface TokenHistoryItem20 { + transaction_hash: string; + token_address: string; + created_at: string; + sender: string; + recipient: string; + value: number; +} + +export interface TokenMetadata721 { + address: string; + name: string; + symbol: string; + created_at: string; +} + +// Materialized View +export interface TokenERC20 { + account_address: string; + balance: number; + name: string; + symbol: string; + total_supply: number; + token_address: string; +} diff --git a/packages/cacti-ledger-browser/src/main/typescript/apps/fabric/components/CertificateDetails/CertificateDetailsBox.tsx b/packages/cacti-ledger-browser/src/main/typescript/apps/fabric/components/CertificateDetails/CertificateDetailsBox.tsx index b7d61dc634..b094946b94 100644 --- a/packages/cacti-ledger-browser/src/main/typescript/apps/fabric/components/CertificateDetails/CertificateDetailsBox.tsx +++ b/packages/cacti-ledger-browser/src/main/typescript/apps/fabric/components/CertificateDetails/CertificateDetailsBox.tsx @@ -3,7 +3,7 @@ import Typography from "@mui/material/Typography"; import TextField from "@mui/material/TextField"; import { styled } from "@mui/material/styles"; -import { FabricCertificate } from "../../fabric-supabase-types"; +import { FabricCertificate } from "../../supabase-types"; import StackedRowItems from "../../../../components/ui/StackedRowItems"; const ListHeaderTypography = styled(Typography)(({ theme }) => ({ diff --git a/packages/cacti-ledger-browser/src/main/typescript/apps/fabric/hooks.tsx b/packages/cacti-ledger-browser/src/main/typescript/apps/fabric/hooks.tsx new file mode 100644 index 0000000000..336d2dcc13 --- /dev/null +++ b/packages/cacti-ledger-browser/src/main/typescript/apps/fabric/hooks.tsx @@ -0,0 +1,16 @@ +import { useOutletContext } from "react-router-dom"; +import { AppInstancePersistencePluginOptions } from "../../common/types/app"; + +export function useFabricAppConfig() { + return useOutletContext(); +} + +export function useFabricSupabaseConfig() { + const appConfig = useFabricAppConfig(); + + return { + schema: appConfig.supabaseSchema, + url: appConfig.supabaseUrl, + key: appConfig.supabaseKey, + }; +} diff --git a/packages/cacti-ledger-browser/src/main/typescript/apps/fabric/index.tsx b/packages/cacti-ledger-browser/src/main/typescript/apps/fabric/index.tsx index 437410665c..7f4ede78fa 100644 --- a/packages/cacti-ledger-browser/src/main/typescript/apps/fabric/index.tsx +++ b/packages/cacti-ledger-browser/src/main/typescript/apps/fabric/index.tsx @@ -1,57 +1,93 @@ +import { Outlet } from "react-router-dom"; + import Dashboard from "./pages/Dashboard/Dashboard"; import Blocks from "./pages/Blocks/Blocks"; import Transactions from "./pages/Transactions/Transactions"; -import { Outlet } from "react-router-dom"; import TransactionDetails from "./pages/TransactionDetails/TransactionDetails"; -import { AppConfig } from "../../common/types/app"; +import { + AppInstancePersistencePluginOptions, + AppDefinition, +} from "../../common/types/app"; import { usePersistenceAppStatus } from "../../common/hook/use-persistence-app-status"; import PersistencePluginStatus from "../../components/PersistencePluginStatus/PersistencePluginStatus"; +import { GuiAppConfig } from "../../common/supabase-types"; +import { AppCategory } from "../../common/app-category"; -const fabricConfig: AppConfig = { +const fabricBrowserAppDefinition: AppDefinition = { appName: "Hyperledger Fabric Browser", - options: { - instanceName: "Fabric", - description: - "Applicaion for browsing Hyperledger Fabric ledger blocks and transactions. Requires Fabric persistence plugin to work correctly.", - path: "/fabric", + category: AppCategory.LedgerBrowser, + defaultInstanceName: "My Fabric Browser", + defaultDescription: + "Application for browsing Hyperledger Fabric ledger blocks and transactions. Requires Fabric persistence plugin to work correctly.", + defaultPath: "/fabric", + defaultOptions: { + supabaseUrl: "http://localhost:8000", + supabaseKey: + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE", + supabaseSchema: "fabric", }, - menuEntries: [ - { - title: "Dashboard", - url: "/", - }, - ], - routes: [ - { - element: , - }, - { - path: "blocks", - element: , - }, - { - path: "transactions", - element: , - }, - { - path: "transaction", - element: , - children: [ + + createAppInstance(app: GuiAppConfig) { + const supabaseOptions = + app.options as any as AppInstancePersistencePluginOptions; + + if ( + !supabaseOptions || + !supabaseOptions.supabaseUrl || + !supabaseOptions.supabaseKey || + !supabaseOptions.supabaseSchema + ) { + throw new Error( + `Invalid fabric app specific options in the database: ${JSON.stringify(supabaseOptions)}`, + ); + } + + return { + id: app.id, + appName: "Hyperledger Fabric Browser", + instanceName: app.instance_name, + description: app.description, + path: app.path, + options: supabaseOptions, + menuEntries: [ + { + title: "Dashboard", + url: "/", + }, + ], + routes: [ + { + element: , + }, + { + path: "blocks", + element: , + }, + { + path: "transactions", + element: , + }, { - path: ":hash", - element: ( -
- -
- ), + path: "transaction", + element: , + children: [ + { + path: ":hash", + element: ( +
+ +
+ ), + }, + ], }, ], - }, - ], - useAppStatus: () => usePersistenceAppStatus("PluginPersistenceFabric"), - StatusComponent: ( - - ), + useAppStatus: () => usePersistenceAppStatus("PluginPersistenceFabric"), + StatusComponent: ( + + ), + }; + }, }; -export default fabricConfig; +export default fabricBrowserAppDefinition; diff --git a/packages/cacti-ledger-browser/src/main/typescript/apps/fabric/pages/TransactionDetails/TranactionInfoCard.tsx b/packages/cacti-ledger-browser/src/main/typescript/apps/fabric/pages/TransactionDetails/TranactionInfoCard.tsx index c1be9680dd..d7d0f66fb7 100644 --- a/packages/cacti-ledger-browser/src/main/typescript/apps/fabric/pages/TransactionDetails/TranactionInfoCard.tsx +++ b/packages/cacti-ledger-browser/src/main/typescript/apps/fabric/pages/TransactionDetails/TranactionInfoCard.tsx @@ -4,7 +4,7 @@ import Typography from "@mui/material/Typography"; import Paper from "@mui/material/Paper"; import Skeleton from "@mui/material/Skeleton"; -import { FabricTransaction } from "../../fabric-supabase-types"; +import { FabricTransaction } from "../../supabase-types"; import ShortenedTypography from "../../../../components/ui/ShortenedTypography"; import StackedRowItems from "../../../../components/ui/StackedRowItems"; diff --git a/packages/cacti-ledger-browser/src/main/typescript/apps/fabric/pages/TransactionDetails/TransactionActionsTable.tsx b/packages/cacti-ledger-browser/src/main/typescript/apps/fabric/pages/TransactionDetails/TransactionActionsTable.tsx index 947e3ed890..b2a8f81b2b 100644 --- a/packages/cacti-ledger-browser/src/main/typescript/apps/fabric/pages/TransactionDetails/TransactionActionsTable.tsx +++ b/packages/cacti-ledger-browser/src/main/typescript/apps/fabric/pages/TransactionDetails/TransactionActionsTable.tsx @@ -19,7 +19,7 @@ import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; import { fabricTransactionActions } from "../../queries"; -import { FabricTransactionAction } from "../../fabric-supabase-types"; +import { FabricTransactionAction } from "../../supabase-types"; import { StyledTableCellHeader, StyledTableCell, diff --git a/packages/cacti-ledger-browser/src/main/typescript/apps/fabric/queries.ts b/packages/cacti-ledger-browser/src/main/typescript/apps/fabric/queries.ts index 48343c52ef..5c4e62606b 100644 --- a/packages/cacti-ledger-browser/src/main/typescript/apps/fabric/queries.ts +++ b/packages/cacti-ledger-browser/src/main/typescript/apps/fabric/queries.ts @@ -3,7 +3,7 @@ * @todo Move to separate directory if this file becomes too complex. */ -import { createClient } from "@supabase/supabase-js"; +import { SupabaseClient, createClient } from "@supabase/supabase-js"; import { queryOptions } from "@tanstack/react-query"; import { FabricBlock, @@ -11,16 +11,10 @@ import { FabricTransaction, FabricTransactionAction, FabricTransactionActionEndorsement, -} from "./fabric-supabase-types"; +} from "./supabase-types"; +import { useFabricSupabaseConfig } from "./hooks"; -// TODO - Configure for an app -const supabaseQueryKey = "supabase:fabric"; -const supabaseUrl = "http://localhost:8000"; -const supabaseKey = - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE"; -export const supabase = createClient(supabaseUrl, supabaseKey, { - schema: "fabric", -}); +let supabase: SupabaseClient | undefined; function createQueryKey( tableName: string, @@ -29,12 +23,25 @@ function createQueryKey( return [tableName, { pagination }]; } +function useSupabaseClient(): [SupabaseClient, string] { + const supabaseConfig = useFabricSupabaseConfig(); + + if (!supabase) { + supabase = createClient(supabaseConfig.url, supabaseConfig.key, { + schema: supabaseConfig.schema, + }); + } + + return [supabase, `supabase:${supabaseConfig.schema}`]; +} + /** * Get all recorded fabric blocks. * Returns `queryOptions` to be used as argument to `useQuery` from `react-query`. * Supports paging. */ export function fabricAllBlocksQuery(page: number, pageSize: number) { + const [supabase, supabaseQueryKey] = useSupabaseClient(); const fromIndex = page * pageSize; const toIndex = fromIndex + pageSize - 1; const tableName = "block"; @@ -64,6 +71,7 @@ export function fabricAllBlocksQuery(page: number, pageSize: number) { * Supports paging. */ export function fabricAllTransactionsQuery(page: number, pageSize: number) { + const [supabase, supabaseQueryKey] = useSupabaseClient(); const fromIndex = page * pageSize; const toIndex = fromIndex + pageSize - 1; const tableName = "transaction"; @@ -92,6 +100,7 @@ export function fabricAllTransactionsQuery(page: number, pageSize: number) { * Get transaction object form the database using it's hash. */ export function fabricTransactionByHash(hash: string) { + const [supabase, supabaseQueryKey] = useSupabaseClient(); const tableName = "transaction"; return queryOptions({ @@ -123,6 +132,7 @@ export function fabricTransactionByHash(hash: string) { * Get transaction actions form the database using parent transaction id. */ export function fabricTransactionActions(txId: string) { + const [supabase, supabaseQueryKey] = useSupabaseClient(); const tableName = "transaction_action"; return queryOptions({ @@ -148,6 +158,7 @@ export function fabricTransactionActions(txId: string) { * Get fabric certificate using it's ID. */ export function fabricCertificate(id: string) { + const [supabase, supabaseQueryKey] = useSupabaseClient(); const tableName = "certificate"; return queryOptions({ @@ -179,6 +190,7 @@ export function fabricCertificate(id: string) { * Get transaction action endorsements form the database using parent action id. */ export function fabricActionEndorsements(actionId: string) { + const [supabase, supabaseQueryKey] = useSupabaseClient(); const tableName = "transaction_action_endorsement"; return queryOptions({ diff --git a/packages/cacti-ledger-browser/src/main/typescript/apps/fabric/fabric-supabase-types.ts b/packages/cacti-ledger-browser/src/main/typescript/apps/fabric/supabase-types.ts similarity index 100% rename from packages/cacti-ledger-browser/src/main/typescript/apps/fabric/fabric-supabase-types.ts rename to packages/cacti-ledger-browser/src/main/typescript/apps/fabric/supabase-types.ts diff --git a/packages/cacti-ledger-browser/src/main/typescript/common/app-category.tsx b/packages/cacti-ledger-browser/src/main/typescript/common/app-category.tsx new file mode 100644 index 0000000000..7fff430e35 --- /dev/null +++ b/packages/cacti-ledger-browser/src/main/typescript/common/app-category.tsx @@ -0,0 +1,34 @@ +import WebIcon from "@mui/icons-material/Web"; +import DnsIcon from "@mui/icons-material/Dns"; +import TokenIcon from "@mui/icons-material/Token"; + +export enum AppCategory { + LedgerBrowser = "ledgerBrowser", + Connector = "connector", + SampleApp = "sampleApp", +} + +export function getAppCategoryConfig(appConfig: AppCategory) { + switch (appConfig) { + case AppCategory.LedgerBrowser: + return { + name: "Ledger Browser", + description: "Browse and analyse ledger data persisted in a database", + icon: , + }; + case AppCategory.Connector: + return { + name: "Connector", + description: "Interact with ledgers through Cacti connectors", + icon: , + }; + case AppCategory.SampleApp: + return { + name: "Sample App", + description: "Run sample Cacti application", + icon: , + }; + default: + throw new Error(`Unknown App Category provided: ${appConfig}`); + } +} diff --git a/packages/cacti-ledger-browser/src/main/typescript/common/config.tsx b/packages/cacti-ledger-browser/src/main/typescript/common/config.tsx index 102724e64e..983a8cbcac 100644 --- a/packages/cacti-ledger-browser/src/main/typescript/common/config.tsx +++ b/packages/cacti-ledger-browser/src/main/typescript/common/config.tsx @@ -1,5 +1,10 @@ -import ethereumGuiConfig from "../apps/eth"; -import fabricAppConfig from "../apps/fabric"; -import { AppConfig } from "./types/app"; +import ethBrowserAppDefinition from "../apps/eth"; +import fabricBrowserAppDefinition from "../apps/fabric"; +import { AppDefinition } from "./types/app"; -export const appConfig: AppConfig[] = [ethereumGuiConfig, fabricAppConfig]; +const config = new Map([ + ["ethereumPersistenceBrowser", ethBrowserAppDefinition], + ["fabricPersistenceBrowser", fabricBrowserAppDefinition], +]); + +export default config; diff --git a/packages/cacti-ledger-browser/src/main/typescript/common/createApplications.tsx b/packages/cacti-ledger-browser/src/main/typescript/common/createApplications.tsx new file mode 100644 index 0000000000..a41292391c --- /dev/null +++ b/packages/cacti-ledger-browser/src/main/typescript/common/createApplications.tsx @@ -0,0 +1,29 @@ +import config from "./config"; +import { GuiAppConfig } from "./supabase-types"; +import { AppInstance } from "./types/app"; + +export default function createApplications(appsFromDb?: GuiAppConfig[]) { + const appConfig = [] as AppInstance[]; + + if (!appsFromDb) { + return appConfig; + } + + for (const app of appsFromDb) { + try { + const appDefinition = config.get(app.app_id); + + if (!appDefinition) { + throw new Error( + `Unknown app ID found in the database - ${app.app_id}, ensure you're using latest GUI version!`, + ); + } + + appConfig.push(appDefinition.createAppInstance(app)); + } catch (error) { + console.error(`Could not add app ${app.app_id}: ${error}`); + } + } + + return appConfig; +} diff --git a/packages/cacti-ledger-browser/src/main/typescript/common/queries.ts b/packages/cacti-ledger-browser/src/main/typescript/common/queries.ts index 11eff447eb..5b7fc81f56 100644 --- a/packages/cacti-ledger-browser/src/main/typescript/common/queries.ts +++ b/packages/cacti-ledger-browser/src/main/typescript/common/queries.ts @@ -1,18 +1,32 @@ -import { createClient } from "@supabase/supabase-js"; -import { queryOptions } from "@tanstack/react-query"; -import { PluginStatus } from "./supabase-types"; +import { SupabaseClient, createClient } from "@supabase/supabase-js"; +import { QueryClient, queryOptions } from "@tanstack/react-query"; +import { GuiAppConfig, PluginStatus } from "./supabase-types"; +import { AddGuiAppConfigType, UpdateGuiAppConfigType } from "./types/app"; -const supabaseQueryKey = "supabase"; -const supabaseUrl = "http://localhost:8000"; -const supabaseKey = - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE"; +let supabase: SupabaseClient | undefined; -export const supabase = createClient(supabaseUrl, supabaseKey); +/** + * Get or initialize (if not already done) a supabase client using environment variables. + */ +function getSupabaseClient(): [SupabaseClient, string] { + const supabaseUrl = import.meta.env.VITE_SUPABASE_URL; + const supabaseKey = import.meta.env.VITE_SUPABASE_KEY; + const supabaseSchema = import.meta.env.VITE_SUPABASE_SCHEMA; + + if (!supabase) { + supabase = createClient(supabaseUrl, supabaseKey, { + schema: supabaseSchema, + }); + } + + return [supabase, `supabase:${supabaseSchema}`]; +} /** * Get persistence plugin status from the database using it's name. */ export function persistencePluginStatus(name: string) { + const [supabase, supabaseQueryKey] = getSupabaseClient(); const tableName = "plugin_status"; return queryOptions({ @@ -39,3 +53,124 @@ export function persistencePluginStatus(name: string) { }, }); } + +/** + * Get persistence plugin app config from the database. + */ +export function guiAppConfig() { + const [supabase, supabaseQueryKey] = getSupabaseClient(); + const tableName = "gui_app_config"; + + return queryOptions({ + queryKey: [supabaseQueryKey, tableName], + queryFn: async () => { + const { data, error } = await supabase.from(tableName).select(); + + if (error) { + throw new Error( + `Could not get GUI App configuration: ${error.message}`, + ); + } + + return data as GuiAppConfig[]; + }, + }); +} + +/** + * Get single persistence plugin app instance infofrom the database. + */ +export function guiAppConfigById(id: string) { + const [supabase, supabaseQueryKey] = getSupabaseClient(); + const tableName = "gui_app_config"; + + return queryOptions({ + queryKey: [supabaseQueryKey, tableName, id], + queryFn: async () => { + const { data, error } = await supabase + .from(tableName) + .select() + .eq("id", id); + + if (error) { + throw new Error( + `Could not get app instance (id ${id}) configuration: ${error.message}`, + ); + } + + if (data.length !== 1) { + throw new Error( + `Invalid response when getting app instance with id ${id}: ${data}`, + ); + } + + return data.pop() as GuiAppConfig; + }, + }); +} + +/** + * Invalidate all queries from gui_app_config. + * Call after each mutation that affects this table. + */ +export function invalidateGuiAppConfig(queryClient: QueryClient) { + const [, supabaseQueryKey] = getSupabaseClient(); + queryClient.invalidateQueries({ + queryKey: [supabaseQueryKey, "gui_app_config"], + }); +} + +/** + * Add new GUI app configuration to the database. + */ +export async function addGuiAppConfig(appData: AddGuiAppConfigType) { + const [supabase] = getSupabaseClient(); + const { data, error } = await supabase + .from("gui_app_config") + .insert([appData]); + + if (error) { + throw new Error(`Could not insert GUI App configuration: ${error.message}`); + } + + return data; +} + +/** + * Update GUI app configuration in the database. + */ +export async function updateGuiAppConfig( + id: string, + appData: UpdateGuiAppConfigType, +) { + const [supabase] = getSupabaseClient(); + const { data, error } = await supabase + .from("gui_app_config") + .update([appData]) + .eq("id", id); + + if (error) { + throw new Error( + `Could not update GUI App ${id} configuration: ${error.message}`, + ); + } + + return data; +} + +/** + * Delete GUI app configuration from the database. + */ +export async function deleteGuiAppConfig(id: string) { + const [supabase] = getSupabaseClient(); + const { data, error } = await supabase + .from("gui_app_config") + .delete() + .eq("id", id); + + if (error) { + throw new Error(`Could not delete GUI App ${id}, error: ${error.message}`); + } + + return data; +} diff --git a/packages/cacti-ledger-browser/src/main/typescript/common/supabase-client.tsx b/packages/cacti-ledger-browser/src/main/typescript/common/supabase-client.tsx deleted file mode 100644 index 6a609c7e1f..0000000000 --- a/packages/cacti-ledger-browser/src/main/typescript/common/supabase-client.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { createClient } from "@supabase/supabase-js"; -import { queryOptions } from "@tanstack/react-query"; - -export const supabaseQueryKey = "supabase"; -const supabaseUrl = "http://localhost:8000"; -const supabaseKey = - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE"; -export const supabase = createClient(supabaseUrl, supabaseKey); - -/** - * React Query config to fetch entire table from supabase. - */ -export function supabaseQueryTable(tableName: string) { - return queryOptions({ - queryKey: [supabaseQueryKey, tableName], - queryFn: async () => { - const { data, error } = await supabase.from(tableName).select(); - if (error) { - throw new Error( - `Could not get data from '${tableName}' table: ${error.message}`, - ); - } - - return data as T; - }, - }); -} - -async function getMatchingTableEntries( - tableName: string, - query: Record, -) { - const { data, error } = await supabase.from(tableName).select().match(query); - if (error) { - throw new Error( - `Could not get data from '${tableName}' table using query '${query}': ${error.message}`, - ); - } - - return data; -} - -export function supabaseQueryAllMatchingEntries( - tableName: string, - query: Record, -) { - return queryOptions({ - queryKey: [supabaseQueryKey, tableName, query], - queryFn: () => { - return getMatchingTableEntries(tableName, query) as T; - }, - }); -} - -export function supabaseQuerySingleMatchingEntry( - tableName: string, - query: Record, -) { - return queryOptions({ - queryKey: [supabaseQueryKey, tableName, query], - queryFn: async () => { - const data = await getMatchingTableEntries(tableName, query); - if (data.length > 1) { - console.warn(`${tableName} query ${query} returned more than 1 entry!`); - } - return data[0] as T; - }, - }); -} diff --git a/packages/cacti-ledger-browser/src/main/typescript/common/supabase-types.ts b/packages/cacti-ledger-browser/src/main/typescript/common/supabase-types.ts index 465a7701f4..972480ffe6 100644 --- a/packages/cacti-ledger-browser/src/main/typescript/common/supabase-types.ts +++ b/packages/cacti-ledger-browser/src/main/typescript/common/supabase-types.ts @@ -1,114 +1,3 @@ -export interface ERC20Txn { - account_address: string; - token_address: string; - uri: string; - token_id: number; - id: string; - balance: number; - last_owner_change: string; -} - -export interface ERC721Txn { - account_address: string; - token_address: string; - uri: string; - token_id: number; - id: string; - last_owner_change: string; -} - -export interface TokenMetadata20 { - address: string; - name: string; - symbol: string; - total_supply: number; - created_at: string; -} - -export interface TokenMetadata721 { - address: string; - name: string; - symbol: string; - created_at: string; -} - -export interface Block { - number: number; - created_at: string; - hash: string; - number_of_tx: number; - sync_at: string; -} - -export interface TokenTransfer { - transaction_id: string; - sender: string; - recipient: string; - value: number; - id: string; -} - -export interface Transaction { - index: number; - hash: string; - block_number: number; - from: string; - to: string; - eth_value: number; - method_signature: string; - method_name: string; - id: string; -} - -export interface TokenHistoryItem { - transaction_hash: string; - token_address: string; - created_at: string; - sender: string; - recipient: string; -} - -export interface TokenHistoryItem721 extends TokenHistoryItem { - token_id: number; -} - -export interface TokenHistoryItem20 extends TokenHistoryItem { - value: number; -} - -export interface TokenTransactionMetadata721 { - account_address: string; - token_address: string; - uri: string; - symbol: string; -} - -export interface TableProperty { - display: string; - objProp: string[]; -} - -export interface TableRowClick { - action: (param: string) => void; - prop: string; -} -export interface TableProps { - onClick: TableRowClick; - schema: TableProperty[]; -} - -/// MANUAL EDITS - -// Materialized View -export interface TokenERC20 { - account_address: string; - balance: number; - name: string; - symbol: string; - total_supply: number; - token_address: string; -} - export interface PluginStatus { name: string; last_instance_id: string; @@ -116,3 +5,14 @@ export interface PluginStatus { created_at: string; last_connected_at: string; } + +export interface GuiAppConfig { + id: string; + app_id: string; + instance_name: string; + description: string; + path: string; + options: Record; + created_at: string; + updated_at: string; +} diff --git a/packages/cacti-ledger-browser/src/main/typescript/common/types/app.ts b/packages/cacti-ledger-browser/src/main/typescript/common/types/app.ts index 3374b557aa..7da191ab78 100644 --- a/packages/cacti-ledger-browser/src/main/typescript/common/types/app.ts +++ b/packages/cacti-ledger-browser/src/main/typescript/common/types/app.ts @@ -1,12 +1,13 @@ import React from "react"; import { RouteObject } from "react-router-dom"; +import { GuiAppConfig } from "../supabase-types"; export interface AppListEntry { path: string; name: string; } -export interface AppConfigMenuEntry { +export interface AppInstanceMenuEntry { title: string; url: string; } @@ -22,17 +23,42 @@ export interface GetStatusResponse { status: AppStatus; } -export interface AppConfigOptions { - instanceName: string; - description: string | undefined; - path: string; +export interface AppInstancePersistencePluginOptions { + supabaseSchema: string; + supabaseUrl: string; + supabaseKey: string; } -export interface AppConfig { +export interface AppInstance { + id: string; appName: string; - options: AppConfigOptions; - menuEntries: AppConfigMenuEntry[]; + instanceName: string; + description: string | undefined; + path: string; + options: T; + menuEntries: AppInstanceMenuEntry[]; routes: RouteObject[]; useAppStatus: () => GetStatusResponse; StatusComponent: React.ReactElement; } + +export type CreateAppInstanceFactoryType = (app: GuiAppConfig) => AppInstance; + +export interface AppDefinition { + appName: string; + category: string; + defaultInstanceName: string; + defaultDescription: string; + defaultPath: string; + defaultOptions: unknown; + createAppInstance: CreateAppInstanceFactoryType; +} + +export type UpdateGuiAppConfigType = { + instance_name: string; + description: string; + path: string; + options: unknown; +}; + +export type AddGuiAppConfigType = UpdateGuiAppConfigType & { app_id: string }; diff --git a/packages/cacti-ledger-browser/src/main/typescript/components/AppSetupForms/AppOptionsForm.tsx b/packages/cacti-ledger-browser/src/main/typescript/components/AppSetupForms/AppOptionsForm.tsx new file mode 100644 index 0000000000..ef266b946e --- /dev/null +++ b/packages/cacti-ledger-browser/src/main/typescript/components/AppSetupForms/AppOptionsForm.tsx @@ -0,0 +1,46 @@ +import * as React from "react"; +import Box from "@mui/material/Box"; +import TextField from "@mui/material/TextField"; + +export interface AppOptionsFormProps { + validationError: string; + setValidationError: React.Dispatch>; + appOptionsJsonString: string; + setAppOptionsJsonString: React.Dispatch>; +} + +/** + * Form component for editing app options (app specific settings) + */ +export default function AppOptionsForm({ + validationError, + setValidationError, + appOptionsJsonString, + setAppOptionsJsonString, +}: AppOptionsFormProps) { + return ( + + { + setValidationError(""); + setAppOptionsJsonString(e.target.value); + }} + /> + + ); +} diff --git a/packages/cacti-ledger-browser/src/main/typescript/components/AppSetupForms/AppSetupForm.tsx b/packages/cacti-ledger-browser/src/main/typescript/components/AppSetupForms/AppSetupForm.tsx new file mode 100644 index 0000000000..a93788bed5 --- /dev/null +++ b/packages/cacti-ledger-browser/src/main/typescript/components/AppSetupForms/AppSetupForm.tsx @@ -0,0 +1,92 @@ +import * as React from "react"; +import Box from "@mui/material/Box"; +import TextField from "@mui/material/TextField"; + +const emptyFormHelperText = "Field can't be empty"; +const regularPathHelperText = + "Path under which the plugin will be available, must be unique withing GUI."; +const illformedPathHelperText = "Must be valid path (starting with '/')"; + +export interface CommonSetupFormValues { + instanceName: string; + description: string; + path: string; +} + +export interface AppSetupFormProps { + commonSetupValues: CommonSetupFormValues; + setCommonSetupValues: React.Dispatch< + React.SetStateAction + >; +} + +/** + * Form component for editing common app options. + */ +export default function AppSetupForm({ + commonSetupValues, + setCommonSetupValues, +}: AppSetupFormProps) { + const isInstanceNameEmptyError = !!!commonSetupValues.instanceName; + const isDescriptionEmptyError = !!!commonSetupValues.description; + const isPathEmptyError = !!!commonSetupValues.path; + const isPathInvalidError = !( + commonSetupValues.path.startsWith("/") && commonSetupValues.path.length > 1 + ); + let pathHelperText = regularPathHelperText; + if (isPathEmptyError) { + pathHelperText = emptyFormHelperText; + } else if (isPathInvalidError) { + pathHelperText = illformedPathHelperText; + } + + const handleChange = ( + e: React.ChangeEvent, + ) => { + setCommonSetupValues({ + ...commonSetupValues, + [e.target.name]: e.target.value, + }); + }; + + return ( + + + + + + ); +} diff --git a/packages/cacti-ledger-browser/src/main/typescript/components/ConnectionFailedDialog/ConnectionFailedDialog.tsx b/packages/cacti-ledger-browser/src/main/typescript/components/ConnectionFailedDialog/ConnectionFailedDialog.tsx new file mode 100644 index 0000000000..d1b692c541 --- /dev/null +++ b/packages/cacti-ledger-browser/src/main/typescript/components/ConnectionFailedDialog/ConnectionFailedDialog.tsx @@ -0,0 +1,79 @@ +import * as React from "react"; +import Dialog from "@mui/material/Dialog"; +import Typography from "@mui/material/Typography"; +import Slide from "@mui/material/Slide"; +import Box from "@mui/material/Box"; +import Paper from "@mui/material/Paper"; +import { TransitionProps } from "@mui/material/transitions"; + +const Transition = React.forwardRef(function Transition( + props: TransitionProps & { + children: React.ReactElement; + }, + ref: React.Ref, +) { + return ; +}); + +/** + * Error dialog that covers entire screen in case of connection error. + * Can't be closed or dismissed. + * + * @todo extend the guidliness, link to the documentation once it's ready. + */ +export default function ConnectionFailedDialog() { + // const supabaseUrl = import.meta.env.VITE_SUPABASE_URL; + // const supabaseKey = import.meta.env.VITE_SUPABASE_KEY; + // const supabaseSchema = import.meta.env.VITE_SUPABASE_SCHEMA; + + return ( + + + + + Connection Error + + + + We were unable to connect to the Supabase instance containing the + app configuration data for this GUI. + + + Please follow the setup guide to resolve the issue. + + + {/* + Connection Details + +
    +
  • + VITE_SUPABASE_URL:{" "} + {supabaseUrl} +
  • +
  • + VITE_SUPABASE_KEY:{" "} + {supabaseKey} +
  • +
  • + VITE_SUPABASE_SCHEMA:{" "} + {supabaseSchema} +
  • +
+ + + If the connection details are invalid, please update them in the + .env file, then rebuild and run the application again. + */} +
+
+
+ ); +} diff --git a/packages/cacti-ledger-browser/src/main/typescript/components/Layout/HeaderBar.tsx b/packages/cacti-ledger-browser/src/main/typescript/components/Layout/HeaderBar.tsx index 7f23912c14..14b1b6c4d9 100644 --- a/packages/cacti-ledger-browser/src/main/typescript/components/Layout/HeaderBar.tsx +++ b/packages/cacti-ledger-browser/src/main/typescript/components/Layout/HeaderBar.tsx @@ -7,13 +7,13 @@ import IconButton from "@mui/material/IconButton"; import AppsIcon from "@mui/icons-material/Apps"; import Button from "@mui/material/Button"; import Tooltip from "@mui/material/Tooltip"; -import { AppConfigMenuEntry, AppListEntry } from "../../common/types/app"; +import { AppInstanceMenuEntry, AppListEntry } from "../../common/types/app"; import { patchAppRoutePath } from "../../common/utils"; type HeaderBarProps = { appList: AppListEntry[]; path?: string; - menuEntries?: AppConfigMenuEntry[]; + menuEntries?: AppInstanceMenuEntry[]; }; const HeaderBar: React.FC = ({ path, menuEntries }) => { diff --git a/packages/cacti-ledger-browser/src/main/typescript/main.tsx b/packages/cacti-ledger-browser/src/main/typescript/main.tsx index 610c719003..06a555e567 100644 --- a/packages/cacti-ledger-browser/src/main/typescript/main.tsx +++ b/packages/cacti-ledger-browser/src/main/typescript/main.tsx @@ -3,11 +3,10 @@ import "@mui/material/styles/styled"; import * as React from "react"; import * as ReactDOM from "react-dom/client"; -import { appConfig } from "./common/config"; import CactiLedgerBrowserApp from "./CactiLedgerBrowserApp"; ReactDOM.createRoot(document.getElementById("root")!).render( - + , ); diff --git a/packages/cacti-ledger-browser/src/main/typescript/pages/add-new-app/AddNewApp.tsx b/packages/cacti-ledger-browser/src/main/typescript/pages/add-new-app/AddNewApp.tsx new file mode 100644 index 0000000000..7e96de45d9 --- /dev/null +++ b/packages/cacti-ledger-browser/src/main/typescript/pages/add-new-app/AddNewApp.tsx @@ -0,0 +1,169 @@ +import * as React from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import Box from "@mui/material/Box"; +import Stepper from "@mui/material/Stepper"; +import Step from "@mui/material/Step"; +import StepLabel from "@mui/material/StepLabel"; + +import config from "../../common/config"; +import { AppCategory } from "../../common/app-category"; +import { addGuiAppConfig, invalidateGuiAppConfig } from "../../common/queries"; +import { useNotification } from "../../common/context/NotificationContext"; +import AppSpecificSetupView from "./AppSpecificSetupView"; +import CommonSetupView from "./CommonSetupView"; +import SelectAppView from "./SelectAppView"; +import SelectGroupView from "./SelectGroupView"; + +const steps = [ + "Select Group", + "Select App", + "Common Setup", + "App Specific Setup", +]; + +export interface AddNewAppProps { + handleDone: () => void; +} + +/** + * Complex view with steps used to select and setup new GUI application. + */ +export default function AddNewApp({ handleDone }: AddNewAppProps) { + const queryClient = useQueryClient(); + const addGuiAppMutation = useMutation({ + mutationFn: addGuiAppConfig, + onSuccess: () => invalidateGuiAppConfig(queryClient), + }); + const { showNotification } = useNotification(); + const [activeStep, setActiveStep] = React.useState(0); + const [appCategory, setAppCategory] = React.useState(""); + const [appId, setAppId] = React.useState(""); + const [commonSetupValues, setCommonSetupValues] = React.useState({ + instanceName: "", + description: "", + path: "", + }); + const [appOptionsJsonString, setAppOptionsJsonString] = React.useState(""); + + // Handle app creation error + React.useEffect(() => { + if (addGuiAppMutation.isError) { + showNotification( + `Could not fetch action endorsements: ${addGuiAppMutation.error}`, + "error", + ); + addGuiAppMutation.reset(); + } + }, [addGuiAppMutation.isError]); + + // Handle app creation success + React.useEffect(() => { + if (addGuiAppMutation.isSuccess) { + showNotification( + `Application ${commonSetupValues.instanceName} added successfully`, + "success", + ); + addGuiAppMutation.reset(); + handleDone(); + } + }, [addGuiAppMutation.isSuccess]); + + const handleNextStep = () => { + setActiveStep((prevActiveStep) => prevActiveStep + 1); + }; + + const handleBackStep = () => { + setActiveStep((prevActiveStep) => prevActiveStep - 1); + }; + + // Select current view in a steper + let currentPage: JSX.Element | undefined; + switch (activeStep) { + case 0: + currentPage = ( + { + setAppCategory(category); + handleNextStep(); + }} + /> + ); + break; + case 1: + currentPage = ( + { + setAppId(appId); + + // Fetch setup defaults to be used in later views + const appDefinition = config.get(appId); + if (!appDefinition) { + throw new Error(`Could not find App Definition with id ${appId}`); + } + setCommonSetupValues({ + instanceName: appDefinition.defaultInstanceName, + description: appDefinition.defaultDescription, + path: appDefinition.defaultPath, + }); + setAppOptionsJsonString( + JSON.stringify(appDefinition.defaultOptions, undefined, 2), + ); + + handleNextStep(); + }} + handleBack={() => { + setAppId(""); + setAppCategory(""); + handleBackStep(); + }} + /> + ); + break; + case 2: + currentPage = ( + + ); + break; + case 3: + currentPage = ( + { + addGuiAppMutation.mutate({ + app_id: appId, + instance_name: commonSetupValues.instanceName, + description: commonSetupValues.description, + path: commonSetupValues.path, + options: JSON.parse(appOptionsJsonString), + }); + }} + /> + ); + break; + } + + // Render the stepper view + return ( + + + {steps.map((label) => { + return ( + + {label} + + ); + })} + + {currentPage} + + ); +} diff --git a/packages/cacti-ledger-browser/src/main/typescript/pages/add-new-app/AppSpecificSetupView.tsx b/packages/cacti-ledger-browser/src/main/typescript/pages/add-new-app/AppSpecificSetupView.tsx new file mode 100644 index 0000000000..f75df74ed3 --- /dev/null +++ b/packages/cacti-ledger-browser/src/main/typescript/pages/add-new-app/AppSpecificSetupView.tsx @@ -0,0 +1,72 @@ +import * as React from "react"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Typography from "@mui/material/Typography"; +import LoadingButton from "@mui/lab/LoadingButton"; +import SaveIcon from "@mui/icons-material/Save"; +import AppOptionsForm from "../../components/AppSetupForms/AppOptionsForm"; + +export interface AppSpecificSetupViewProps { + appOptionsJsonString: string; + setAppOptionsJsonString: React.Dispatch>; + handleBack: () => void; + handleSave: () => void; + isSending: boolean; +} + +/** + * Add new app stepper view containing application specific configuration (in form of a JSON to be filled by the user) + */ +export default function AppSpecificSetupView({ + appOptionsJsonString, + setAppOptionsJsonString, + handleBack, + handleSave, + isSending, +}: AppSpecificSetupViewProps) { + const [validationError, setValidationError] = React.useState(""); + + return ( + <> + App Specific Setup + + + + } + variant="contained" + onClick={() => { + // Validate JSON input + try { + JSON.parse(appOptionsJsonString); + } catch (error) { + setValidationError(`Invalid JSON format, error: ${error}`); + return; + } + + handleSave(); + }} + > + Save + + + + ); +} diff --git a/packages/cacti-ledger-browser/src/main/typescript/pages/add-new-app/CommonSetupView.tsx b/packages/cacti-ledger-browser/src/main/typescript/pages/add-new-app/CommonSetupView.tsx new file mode 100644 index 0000000000..f1ee05b741 --- /dev/null +++ b/packages/cacti-ledger-browser/src/main/typescript/pages/add-new-app/CommonSetupView.tsx @@ -0,0 +1,55 @@ +import * as React from "react"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Typography from "@mui/material/Typography"; +import AppSetupForm from "../../components/AppSetupForms/AppSetupForm"; + +export interface CommonSetupFormValues { + instanceName: string; + description: string; + path: string; +} + +export interface CommonSetupViewProps { + commonSetupValues: CommonSetupFormValues; + setCommonSetupValues: React.Dispatch< + React.SetStateAction + >; + handleBack: () => void; + handleNext: () => void; +} + +/** + * Add new app stepper view containing common application configuration (required by all apps). + */ +export default function CommonSetupView({ + commonSetupValues, + setCommonSetupValues, + handleBack, + handleNext, +}: CommonSetupViewProps) { + return ( + <> + Common App Setup + + + + + + + ); +} diff --git a/packages/cacti-ledger-browser/src/main/typescript/pages/add-new-app/SelectAppView.tsx b/packages/cacti-ledger-browser/src/main/typescript/pages/add-new-app/SelectAppView.tsx new file mode 100644 index 0000000000..42663bed25 --- /dev/null +++ b/packages/cacti-ledger-browser/src/main/typescript/pages/add-new-app/SelectAppView.tsx @@ -0,0 +1,59 @@ +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Typography from "@mui/material/Typography"; +import List from "@mui/material/List"; +import ListItemText from "@mui/material/ListItemText"; +import ListItemAvatar from "@mui/material/ListItemAvatar"; +import Avatar from "@mui/material/Avatar"; +import ListItemButton from "@mui/material/ListItemButton"; + +import config from "../../common/config"; +import { AppCategory, getAppCategoryConfig } from "../../common/app-category"; + +export interface SelectAppViewProps { + appCategory: string; + handleAppSelected: (appId: string) => void; + handleBack: () => void; +} + +/** + * Add new app stepper view containing list of app under given category to pick. + */ +export default function SelectAppView({ + appCategory, + handleAppSelected, + handleBack, +}: SelectAppViewProps) { + const apps = Array.from(config).filter( + (app) => app[1].category === appCategory, + ); + const categoryConfig = getAppCategoryConfig(appCategory as AppCategory); + + return ( + <> + Select Application + + + {apps.map(([appId, app]) => { + return ( + handleAppSelected(appId)}> + + {categoryConfig.icon} + + + + ); + })} + + + + + + + ); +} diff --git a/packages/cacti-ledger-browser/src/main/typescript/pages/add-new-app/SelectGroupView.tsx b/packages/cacti-ledger-browser/src/main/typescript/pages/add-new-app/SelectGroupView.tsx new file mode 100644 index 0000000000..5c0cab42a6 --- /dev/null +++ b/packages/cacti-ledger-browser/src/main/typescript/pages/add-new-app/SelectGroupView.tsx @@ -0,0 +1,52 @@ +import Typography from "@mui/material/Typography"; +import List from "@mui/material/List"; +import ListItemText from "@mui/material/ListItemText"; +import ListItemAvatar from "@mui/material/ListItemAvatar"; +import Avatar from "@mui/material/Avatar"; +import ListItemButton from "@mui/material/ListItemButton"; +import config from "../../common/config"; +import { AppCategory, getAppCategoryConfig } from "../../common/app-category"; + +export interface SelectGroupViewProps { + handleCategorySelected: (category: AppCategory) => void; +} + +/** + * Add new app stepper view containing list of app categories to select. + */ +export default function SelectGroupView({ + handleCategorySelected, +}: SelectGroupViewProps) { + const appCategories = Array.from(config.values()).map((app) => app.category); + + return ( + <> + Select Group + + + {Object.values(AppCategory).map((category) => { + const categoryConfig = getAppCategoryConfig(category); + const appCount = appCategories.filter( + (appCat) => appCat === category, + ).length; + const categoryTitle = `${categoryConfig.name} (${appCount})`; + + return ( + handleCategorySelected(category)} + > + + {categoryConfig.icon} + + + + ); + })} + + + ); +} diff --git a/packages/cacti-ledger-browser/src/main/typescript/pages/configure-app/ConfigureApp.tsx b/packages/cacti-ledger-browser/src/main/typescript/pages/configure-app/ConfigureApp.tsx new file mode 100644 index 0000000000..bb1bd64741 --- /dev/null +++ b/packages/cacti-ledger-browser/src/main/typescript/pages/configure-app/ConfigureApp.tsx @@ -0,0 +1,267 @@ +import * as React from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import Box from "@mui/material/Box"; +import Dialog from "@mui/material/Dialog"; +import DialogTitle from "@mui/material/DialogTitle"; +import DialogContent from "@mui/material/DialogContent"; +import Typography from "@mui/material/Typography"; +import CircularProgress from "@mui/material/CircularProgress"; +import Button from "@mui/material/Button"; +import DialogContentText from "@mui/material/DialogContentText"; +import DialogActions from "@mui/material/DialogActions"; +import LoadingButton from "@mui/lab/LoadingButton"; +import SaveIcon from "@mui/icons-material/Save"; +import DeleteIcon from "@mui/icons-material/Delete"; + +import { + deleteGuiAppConfig, + guiAppConfigById, + invalidateGuiAppConfig, + updateGuiAppConfig, +} from "../../common/queries"; +import { UpdateGuiAppConfigType } from "../../common/types/app"; +import { useNotification } from "../../common/context/NotificationContext"; +import AppSetupForm from "../../components/AppSetupForms/AppSetupForm"; +import AppOptionsForm from "../../components/AppSetupForms/AppOptionsForm"; + +type DeleteWithConfirmationButtonProps = { + appInstanceId: string; + handleDone: () => void; +}; + +/** + * Button and logic for removing the application from a database. + */ +function DeleteWithConfirmationButton({ + appInstanceId, + handleDone, +}: DeleteWithConfirmationButtonProps) { + const { showNotification } = useNotification(); + const queryClient = useQueryClient(); + const [openDialog, setOpenDialog] = React.useState(false); + const deleteGuiAppMutation = useMutation({ + mutationFn: () => deleteGuiAppConfig(appInstanceId), + onSuccess: () => invalidateGuiAppConfig(queryClient), + }); + + const handleClose = () => { + setOpenDialog(false); + }; + + // Show error if app can't be deleted from the database + React.useEffect(() => { + if (deleteGuiAppMutation.isError) { + showNotification( + `Could not delete application ${appInstanceId}, error: ${deleteGuiAppMutation.error}`, + "error", + ); + deleteGuiAppMutation.reset(); + } + }, [deleteGuiAppMutation.isError]); + + // Show success message and terminate if data was saved successfully + React.useEffect(() => { + if (deleteGuiAppMutation.isSuccess) { + showNotification( + `Application ${appInstanceId} removed successfully`, + "success", + ); + deleteGuiAppMutation.reset(); + handleDone(); + } + }, [deleteGuiAppMutation.isSuccess]); + + return ( + <> + } + onClick={() => setOpenDialog(true)} + sx={{ marginRight: 1 }} + variant="contained" + > + Delete + + + + Confirm Deletion + + + Are you sure you want to delete this application? This action is + irreversible. You will need to set it up again if you proceed. + + + + + + + + + ); +} + +export interface ConfigureAppProps { + appInstanceId: string; + handleDone: () => void; +} + +/** + * View to edit or delete an application. + */ +export default function ConfigureApp({ + appInstanceId, + handleDone, +}: ConfigureAppProps) { + const queryClient = useQueryClient(); + const { showNotification } = useNotification(); + const [jsonValidationError, setJsonValidationError] = React.useState(""); + const [commonSetupValues, setCommonSetupValues] = React.useState({ + instanceName: "", + description: "", + path: "", + }); + const [appOptionsJsonString, setAppOptionsJsonString] = React.useState(""); + const updateGuiAppMutation = useMutation({ + mutationFn: (data: UpdateGuiAppConfigType) => + updateGuiAppConfig(appInstanceId, data), + onSuccess: () => invalidateGuiAppConfig(queryClient), + }); + const appConfigQuery = useQuery(guiAppConfigById(appInstanceId)); + const appConfigData = appConfigQuery.data; + + // Set current app configuration values to the form once data is received from the database + React.useEffect(() => { + if (appConfigData && !appConfigQuery.isPending) { + setCommonSetupValues({ + instanceName: appConfigData.instance_name, + description: appConfigData.description, + path: appConfigData.path, + }); + setAppOptionsJsonString( + JSON.stringify(appConfigData.options, undefined, 2), + ); + } + }, [appConfigData]); + + // Show error if current data can't be fetched from the database + React.useEffect(() => { + if (appConfigQuery.isError) { + showNotification( + `Could not fetch ${appInstanceId} application config: ${appConfigQuery.error}`, + "error", + ); + } + }, [appConfigQuery.isError]); + + // Show error if updates can't be saved to the database + React.useEffect(() => { + if (updateGuiAppMutation.isError) { + showNotification( + `Could not save ${appInstanceId} updated application config: ${updateGuiAppMutation.error}`, + "error", + ); + updateGuiAppMutation.reset(); + } + }, [updateGuiAppMutation.isError]); + + // Show success message and terminate if data was saved successfully + React.useEffect(() => { + if (updateGuiAppMutation.isSuccess) { + showNotification( + `Application ${commonSetupValues.instanceName} edited successfully`, + "success", + ); + updateGuiAppMutation.reset(); + handleDone(); + } + }, [updateGuiAppMutation.isSuccess]); + + // Render the view + return ( + + {appConfigQuery.isPending && ( + + )} + + Common App Setup + + + + App Specific Setup + + + + + + + + + } + variant="contained" + onClick={() => { + // Validate JSON input + try { + JSON.parse(appOptionsJsonString); + } catch (error) { + setJsonValidationError(`Invalid JSON format, error: ${error}`); + return; + } + + updateGuiAppMutation.mutate({ + instance_name: commonSetupValues.instanceName, + description: commonSetupValues.description, + path: commonSetupValues.path, + options: JSON.parse(appOptionsJsonString), + }); + }} + > + Save + + + + + ); +} diff --git a/packages/cacti-ledger-browser/src/main/typescript/pages/home/AddApplicationPopupCard.tsx b/packages/cacti-ledger-browser/src/main/typescript/pages/home/AddApplicationPopupCard.tsx new file mode 100644 index 0000000000..b01b45bbc4 --- /dev/null +++ b/packages/cacti-ledger-browser/src/main/typescript/pages/home/AddApplicationPopupCard.tsx @@ -0,0 +1,95 @@ +import React from "react"; +import { useTheme } from "@mui/material/styles"; +import Card from "@mui/material/Card"; +import CardActionArea from "@mui/material/CardActionArea"; +import Typography from "@mui/material/Typography"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Dialog from "@mui/material/Dialog"; +import DialogContent from "@mui/material/DialogContent"; +import DialogTitle from "@mui/material/DialogTitle"; +import AddIcon from "@mui/icons-material/Add"; +import CloseIcon from "@mui/icons-material/Close"; +import AddNewApp from "../add-new-app/AddNewApp"; + +export interface NewAppDialogProps { + open: boolean; + setOpen: React.Dispatch>; +} + +function NewAppDialog({ open, setOpen }: NewAppDialogProps) { + const handleClose = () => { + setOpen(false); + }; + + return ( + <> + + + Add Application + + + + + + + + ); +} + +export default function AddApplicationPopupCard() { + const theme = useTheme(); + const [open, setOpen] = React.useState(false); + + return ( + <> + + { + setOpen(true); + }} + sx={{ + flex: 1, + }} + > + + + Add Application + + + + + + ); +} diff --git a/packages/cacti-ledger-browser/src/main/typescript/pages/home/AppCard.tsx b/packages/cacti-ledger-browser/src/main/typescript/pages/home/AppCard.tsx index 5386824c74..3517da884f 100644 --- a/packages/cacti-ledger-browser/src/main/typescript/pages/home/AppCard.tsx +++ b/packages/cacti-ledger-browser/src/main/typescript/pages/home/AppCard.tsx @@ -12,7 +12,8 @@ import CircularProgress from "@mui/material/CircularProgress"; import Button from "@mui/material/Button"; import Typography from "@mui/material/Typography"; -import { AppConfig, AppStatus } from "../../common/types/app"; +import { AppInstance, AppStatus } from "../../common/types/app"; +import ConfigureApp from "../configure-app/ConfigureApp"; type StatusTextProps = { status: AppStatus; @@ -73,8 +74,36 @@ function StatusDialogButton({ statusComponent }: StatusDialogButtonProps) { ); } +type ConfigureDialogButtonProps = { + appInstanceId: string; +}; + +function ConfigureDialogButton({ appInstanceId }: ConfigureDialogButtonProps) { + const [openDialog, setOpenDialog] = React.useState(false); + + return ( + <> + + setOpenDialog(false)} + open={openDialog} + > + Configure Application + + setOpenDialog(false)} + /> + + + + ); +} + type AppCardProps = { - appConfig: AppConfig; + appConfig: AppInstance; }; /** @@ -98,7 +127,7 @@ export default function AppCard({ appConfig }: AppCardProps) { > { - navigate(appConfig.options.path); + navigate(appConfig.path); }} > - {appConfig.options.instanceName} + {appConfig.instanceName} {appConfig.appName} - {appConfig.options.description && ( - - {appConfig.options.description} - + {appConfig.description && ( + {appConfig.description} )} Initialized:{" "} @@ -144,6 +171,7 @@ export default function AppCard({ appConfig }: AppCardProps) { borderColor: theme.palette.primary.main, }} > + diff --git a/packages/cacti-ledger-browser/src/main/typescript/pages/home/HomePage.tsx b/packages/cacti-ledger-browser/src/main/typescript/pages/home/HomePage.tsx index 2539bcbb67..bfac7cfddd 100644 --- a/packages/cacti-ledger-browser/src/main/typescript/pages/home/HomePage.tsx +++ b/packages/cacti-ledger-browser/src/main/typescript/pages/home/HomePage.tsx @@ -1,10 +1,15 @@ import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; -import { appConfig } from "../../common/config"; +import { AppInstance } from "../../common/types/app"; import AppCard from "./AppCard"; +import AddApplicationPopupCard from "./AddApplicationPopupCard"; -export default function HomePage() { +type HomePageProps = { + appConfig: AppInstance[]; +}; + +export default function HomePage({ appConfig }: HomePageProps) { return ( @@ -13,17 +18,13 @@ export default function HomePage() { + {appConfig.map((a) => { - return ( - - ); + return ; })} diff --git a/packages/cacti-ledger-browser/src/main/typescript/vite-env.d.ts b/packages/cacti-ledger-browser/src/main/typescript/vite-env.d.ts index 11f02fe2a0..8b854e00df 100644 --- a/packages/cacti-ledger-browser/src/main/typescript/vite-env.d.ts +++ b/packages/cacti-ledger-browser/src/main/typescript/vite-env.d.ts @@ -1 +1,11 @@ /// + +interface ImportMetaEnv { + readonly VITE_SUPABASE_URL: string + readonly VITE_SUPABASE_KEY: string + readonly VITE_SUPABASE_SCHEMA: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} \ No newline at end of file diff --git a/packages/cacti-ledger-browser/tsconfig.json b/packages/cacti-ledger-browser/tsconfig.json index c46a23bf1a..6fdc580da1 100644 --- a/packages/cacti-ledger-browser/tsconfig.json +++ b/packages/cacti-ledger-browser/tsconfig.json @@ -1,6 +1,8 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { + "target": "esnext", + "module": "esnext", "composite": true, "outDir": "./dist/lib/", "declarationDir": "dist/lib",