diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json index cf4afb727..a0fd8ea60 100644 --- a/frontend/.eslintrc.json +++ b/frontend/.eslintrc.json @@ -29,12 +29,7 @@ "simple-import-sort/imports": "error", "simple-import-sort/exports": "error", "unused-imports/no-unused-imports": "error", - "no-unused-vars": [ - "warn", - { - "argsIgnorePattern": "^_" - } - ], + "no-unused-vars": "off", "no-console": [ "error", { diff --git a/frontend/client/types.ts b/frontend/client/types.ts index ccfd2c36d..f8a4094ff 100644 --- a/frontend/client/types.ts +++ b/frontend/client/types.ts @@ -46,6 +46,7 @@ export type ChainData = { export type MiddlewareServiceResponse = { service_config_id: string; // TODO: update with uuid once middleware integrated + version: number; name: string; hash: string; hash_history: { @@ -54,7 +55,10 @@ export type MiddlewareServiceResponse = { home_chain: MiddlewareChain; keys: ServiceKeys[]; service_path?: string; - version: string; + description: string; + env_variables: { + [key: string]: EnvVariableAttributes; + }; chain_configs: { [middlewareChain: string]: { ledger_config: LedgerConfig; diff --git a/frontend/components/Card/CardTitle.tsx b/frontend/components/Card/CardTitle.tsx index 41b35c231..e0c7e5cfc 100644 --- a/frontend/components/Card/CardTitle.tsx +++ b/frontend/components/Card/CardTitle.tsx @@ -1,8 +1,19 @@ +import { ArrowLeftOutlined } from '@ant-design/icons'; import { Flex, Typography } from 'antd'; +import Button from 'antd/es/button'; +import { isFunction } from 'lodash'; import { ReactNode } from 'react'; -export const CardTitle = ({ title }: { title: string | ReactNode }) => ( - +type CardTitleProps = { + title: string | ReactNode; + backButtonCallback?: () => void; +}; + +export const CardTitle = ({ title, backButtonCallback }: CardTitleProps) => ( + + {isFunction(backButtonCallback) && ( + + + + ); +}; diff --git a/frontend/components/UpdateAgentPage/context/UpdateAgentProvider.tsx b/frontend/components/UpdateAgentPage/context/UpdateAgentProvider.tsx new file mode 100644 index 000000000..a85064d33 --- /dev/null +++ b/frontend/components/UpdateAgentPage/context/UpdateAgentProvider.tsx @@ -0,0 +1,92 @@ +import { Form, FormInstance } from 'antd'; +import { + createContext, + Dispatch, + PropsWithChildren, + SetStateAction, + useCallback, + useState, +} from 'react'; + +import { ServiceTemplate } from '@/client'; +import { Pages } from '@/enums/Pages'; +import { usePageState } from '@/hooks/usePageState'; +import { useServices } from '@/hooks/useServices'; +import { ServicesService } from '@/service/Services'; +import { DeepPartial } from '@/types/Util'; + +import { useConfirmUpdateModal } from '../hooks/useConfirmModal'; +import { ModalProps } from '../hooks/useModal'; +import { useUnsavedModal } from '../hooks/useUnsavedModal'; +import { ConfirmUpdateModal } from '../modals/ConfirmUpdateModal'; +import { UnsavedModal } from '../modals/UnsavedModal'; + +export const UpdateAgentContext = createContext< + Partial<{ + confirmUpdateModal: ModalProps; + unsavedModal: ModalProps; + form: FormInstance; + isEditing: boolean; + setIsEditing: Dispatch>; + }> +>({}); + +export const UpdateAgentProvider = ({ children }: PropsWithChildren) => { + const [form] = Form.useForm>(); + const { selectedService } = useServices(); + const { goto } = usePageState(); + const [isEditing, setIsEditing] = useState(false); + + const confirmUpdateCallback = useCallback(async () => { + const formValues = form.getFieldsValue(); + + if (!selectedService || !selectedService.service_config_id) return; + + try { + await ServicesService.updateService({ + serviceConfigId: selectedService.service_config_id, + partialServiceTemplate: { + ...formValues, + env_variables: { + ...Object.entries(formValues.env_variables ?? {}).reduce( + (acc, [key, value]) => ({ ...acc, [key]: { value } }), + {}, + ), + }, + }, + }); + } catch (error) { + console.error(error); + } finally { + setIsEditing(false); + } + }, [form, selectedService]); + + const confirmUnsavedCallback = useCallback(async () => { + goto(Pages.Main); + }, [goto]); + + const confirmUpdateModal = useConfirmUpdateModal({ + confirmCallback: confirmUpdateCallback, + }); + + const unsavedModal = useUnsavedModal({ + confirmCallback: confirmUnsavedCallback, + }); + + return ( + + + + {children} + + ); +}; diff --git a/frontend/components/UpdateAgentPage/hooks/useConfirmModal.ts b/frontend/components/UpdateAgentPage/hooks/useConfirmModal.ts new file mode 100644 index 000000000..448246f25 --- /dev/null +++ b/frontend/components/UpdateAgentPage/hooks/useConfirmModal.ts @@ -0,0 +1,65 @@ +import { message } from 'antd'; +import { useCallback, useState } from 'react'; + +import { useService } from '@/hooks/useService'; +import { ServicesService } from '@/service/Services'; + +import { useModal } from './useModal'; + +export const useConfirmUpdateModal = ({ + confirmCallback, +}: { + confirmCallback: () => Promise; +}) => { + const modal = useModal(); + const { isServiceRunning, service } = useService(); + + const [pending, setPending] = useState(false); + + const restartIfServiceRunning = useCallback(async () => { + if (isServiceRunning && service?.service_config_id) { + try { + message.info('Restarting service ...'); + await ServicesService.stopDeployment(service.service_config_id); + await ServicesService.startService(service.service_config_id); + } catch (e) { + console.error(e); + } + } + }, [isServiceRunning, service?.service_config_id]); + + const confirm = useCallback(async () => { + setPending(true); + message.loading({ + content: 'Updating agent settings...', + key: 'updating', + }); + let failed = false; + + try { + await confirmCallback(); + message.destroy('updating'); + message.success({ content: 'Agent settings updated successfully.' }); + + // restart may be time consuming, no need to await here + restartIfServiceRunning().catch(() => + message.error({ content: 'Failed to restart service.' }), + ); + } catch (e) { + console.error(e); + failed = true; + } finally { + setPending(false); + } + + if (!failed) return modal.closeModal(); + + throw new Error('Failed to confirm'); + }, [confirmCallback, modal, restartIfServiceRunning]); + + return { + ...modal, + confirm, + pending, + }; +}; diff --git a/frontend/components/UpdateAgentPage/hooks/useModal.ts b/frontend/components/UpdateAgentPage/hooks/useModal.ts new file mode 100644 index 000000000..457e32729 --- /dev/null +++ b/frontend/components/UpdateAgentPage/hooks/useModal.ts @@ -0,0 +1,31 @@ +import { useCallback, useState } from 'react'; + +export type ModalProps = { + open: boolean; + openModal: () => void; + closeModal: () => void; + cancel: () => void; + confirm: () => void; +}; + +export const useModal = (): ModalProps => { + const [open, setOpen] = useState(false); + const openModal = () => setOpen(true); + const closeModal = () => setOpen(false); + + const cancel = useCallback(async () => { + closeModal(); + }, []); + + const confirm = useCallback(async () => { + closeModal(); + }, []); + + return { + open, + openModal, + closeModal, + cancel, + confirm, + }; +}; diff --git a/frontend/components/UpdateAgentPage/hooks/useUnsavedModal.ts b/frontend/components/UpdateAgentPage/hooks/useUnsavedModal.ts new file mode 100644 index 000000000..8993a330c --- /dev/null +++ b/frontend/components/UpdateAgentPage/hooks/useUnsavedModal.ts @@ -0,0 +1,21 @@ +import { useCallback } from 'react'; + +import { ModalProps, useModal } from './useModal'; + +export const useUnsavedModal = ({ + confirmCallback, +}: { + confirmCallback: () => void; +}): ModalProps => { + const modal = useModal(); + + const confirm = useCallback(async () => { + confirmCallback(); + modal.closeModal(); + }, [confirmCallback, modal]); + + return { + ...modal, + confirm, + }; +}; diff --git a/frontend/components/UpdateAgentPage/index.tsx b/frontend/components/UpdateAgentPage/index.tsx new file mode 100644 index 000000000..6f42f3aaa --- /dev/null +++ b/frontend/components/UpdateAgentPage/index.tsx @@ -0,0 +1,118 @@ +import { EditOutlined } from '@ant-design/icons'; +import { Button, ConfigProvider } from 'antd'; +import { get, isEqual, omit } from 'lodash'; +import { useCallback, useContext, useMemo } from 'react'; + +import { AgentType } from '@/enums/Agent'; +import { Pages } from '@/enums/Pages'; +import { usePageState } from '@/hooks/usePageState'; +import { useServices } from '@/hooks/useServices'; +import { LOCAL_FORM_THEME } from '@/theme'; +import { Nullable } from '@/types/Util'; + +import { CardTitle } from '../Card/CardTitle'; +import { CardFlex } from '../styled/CardFlex'; +import { + UpdateAgentContext, + UpdateAgentProvider, +} from './context/UpdateAgentProvider'; +import { MemeUpdateForm } from './MemeUpdateForm'; + +const EditButton = () => { + const { setIsEditing } = useContext(UpdateAgentContext); + + const handleEdit = useCallback(() => { + setIsEditing?.((prev) => !prev); + }, [setIsEditing]); + + return ( + + ); +}; + +type MemeooorrFormValues = { + description: string; + env_variables: { + GENAI_API_KEY: string; + PERSONA: string; + TWIKIT_USERNAME: string; + TWIKIT_EMAIL: string; + TWIKIT_PASSWORD: string; + TWIKIT_COOKIES: string; + }; +}; + +const UpdateAgentForm = () => { + const { goto } = usePageState(); + const { selectedAgentType, selectedService } = useServices(); + const { unsavedModal, isEditing, form } = useContext(UpdateAgentContext); + + const initialValues = useMemo>(() => { + if (!selectedService?.env_variables) return null; + + const envEntries = Object.entries(selectedService.env_variables); + + return envEntries.reduce( + (acc, [key, { value }]) => { + if (key === 'PERSONA') { + acc.env_variables.PERSONA = value; + } else if (key === 'GENAI_API_KEY') { + acc.env_variables.GENAI_API_KEY = value; + } else if (key === 'TWIKIT_EMAIL') { + acc.env_variables.TWIKIT_EMAIL = value; + } else if (key === 'TWIKIT_USERNAME') { + acc.env_variables.TWIKIT_USERNAME = value; + } else if (key === 'TWIKIT_PASSWORD') { + acc.env_variables.TWIKIT_PASSWORD = value; + } + + return acc; + }, + { env_variables: {} } as MemeooorrFormValues, + ); + }, [selectedService?.env_variables]); + + const handleClickBack = useCallback(() => { + const unsavedFields = omit( + get(form?.getFieldsValue(), 'env_variables'), + 'TWIKIT_COOKIES', + ); + const previousValues = initialValues?.env_variables; + + const hasUnsavedChanges = !isEqual(unsavedFields, previousValues); + if (hasUnsavedChanges) { + unsavedModal?.openModal?.(); + } else { + goto(Pages.Main); + } + }, [unsavedModal, goto, form, initialValues]); + + return ( + + } + extra={isEditing ? null : } + > + {selectedAgentType === AgentType.Memeooorr && ( + + )} + + ); +}; + +export const UpdateAgentPage = () => { + return ( + + + + + + ); +}; diff --git a/frontend/components/UpdateAgentPage/modals/ConfirmUpdateModal.tsx b/frontend/components/UpdateAgentPage/modals/ConfirmUpdateModal.tsx new file mode 100644 index 000000000..785c7a156 --- /dev/null +++ b/frontend/components/UpdateAgentPage/modals/ConfirmUpdateModal.tsx @@ -0,0 +1,25 @@ +import { Modal } from 'antd'; +import { useContext } from 'react'; + +import { useService } from '@/hooks/useService'; + +import { UpdateAgentContext } from '../context/UpdateAgentProvider'; + +export const ConfirmUpdateModal = () => { + const { isServiceRunning } = useService(); + const { confirmUpdateModal } = useContext(UpdateAgentContext); + + if (!confirmUpdateModal) return null; + + return ( + + These changes will only take effect when you restart the agent. + + ); +}; diff --git a/frontend/components/UpdateAgentPage/modals/UnsavedModal.tsx b/frontend/components/UpdateAgentPage/modals/UnsavedModal.tsx new file mode 100644 index 000000000..94476af91 --- /dev/null +++ b/frontend/components/UpdateAgentPage/modals/UnsavedModal.tsx @@ -0,0 +1,22 @@ +import { Modal } from 'antd'; +import { useContext } from 'react'; + +import { UpdateAgentContext } from '../context/UpdateAgentProvider'; + +export const UnsavedModal = () => { + const { unsavedModal } = useContext(UpdateAgentContext); + + if (!unsavedModal) return null; + + return ( + + You have unsaved changes. Are you sure you want to leave this page? + + ); +}; diff --git a/frontend/enums/Pages.ts b/frontend/enums/Pages.ts index 3bc285fc5..721495b36 100644 --- a/frontend/enums/Pages.ts +++ b/frontend/enums/Pages.ts @@ -11,4 +11,5 @@ export enum Pages { AddBackupWalletViaSafe, SwitchAgent, AgentActivity, + UpdateAgentTemplate, } diff --git a/frontend/pages/index.tsx b/frontend/pages/index.tsx index 3db0f454e..7547c6b0e 100644 --- a/frontend/pages/index.tsx +++ b/frontend/pages/index.tsx @@ -9,6 +9,7 @@ import { HelpAndSupport } from '@/components/Pages/HelpAndSupportPage'; import { RewardsHistory } from '@/components/RewardsHistory/RewardsHistory'; import { Settings } from '@/components/SettingsPage'; import { Setup } from '@/components/SetupPage'; +import { UpdateAgentPage } from '@/components/UpdateAgentPage'; import { YourWalletPage } from '@/components/YourWalletPage'; import { Pages } from '@/enums/Pages'; import { useElectronApi } from '@/hooks/useElectronApi'; @@ -64,6 +65,8 @@ export default function Home() { return ; case Pages.AgentActivity: return ; + case Pages.UpdateAgentTemplate: + return ; default: return
; } diff --git a/frontend/service/Services.ts b/frontend/service/Services.ts index 86b13ed38..a88cd4647 100644 --- a/frontend/service/Services.ts +++ b/frontend/service/Services.ts @@ -161,7 +161,7 @@ const getDeployment = async (serviceConfigId: string): Promise => * @param serviceTemplate ServiceTemplate * @returns Promise */ -export const withdrawBalance = async ({ +const withdrawBalance = async ({ withdrawAddress, serviceConfigId, }: { diff --git a/frontend/theme/index.ts b/frontend/theme/index.ts index eed4d88ba..5f362f54e 100644 --- a/frontend/theme/index.ts +++ b/frontend/theme/index.ts @@ -48,3 +48,6 @@ export const mainTheme: ThemeConfig = { }, }, }; + +// TODO: consolidate theme into mainTheme +export const LOCAL_FORM_THEME = { components: { Input: { fontSize: 16 } } }; diff --git a/frontend/types/Util.ts b/frontend/types/Util.ts index b39a88304..31ca4bbf5 100644 --- a/frontend/types/Util.ts +++ b/frontend/types/Util.ts @@ -4,6 +4,9 @@ export type Optional = T | undefined; export type Maybe = Nullable>; +/** + * DeepPartial allows you to make all properties of an object optional. + */ export type DeepPartial = { [P in keyof T]?: T[P] extends object ? DeepPartial : T[P]; };