snowballtools-base/packages/frontend/src/components/projects/create/Configure.tsx
zramsay 17640d3133 Copy updates (#38)
Co-authored-by: zramsay <zach@bluecollarcoding.ca>
Co-authored-by: Nabarun <nabarun@deepstacksoft.com>
Reviewed-on: cerc-io/snowballtools-base#38
Co-authored-by: zramsay <zramsay@noreply.git.vdb.to>
Co-committed-by: zramsay <zramsay@noreply.git.vdb.to>
2024-11-14 09:10:19 +00:00

631 lines
20 KiB
TypeScript

import { useCallback, useState, useEffect } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { FormProvider, FieldValues } from 'react-hook-form';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useMediaQuery } from 'usehooks-ts';
import {
AddEnvironmentVariableInput,
AuctionParams,
Deployer,
} from 'gql-client';
import { Select, MenuItem, FormControl, FormHelperText } from '@mui/material';
import {
ArrowRightCircleFilledIcon,
LoadingIcon,
} from 'components/shared/CustomIcon';
import { Heading } from '../../shared/Heading';
import { Button } from '../../shared/Button';
import { Input } from 'components/shared/Input';
import { useToast } from 'components/shared/Toast';
import { useGQLClient } from '../../../context/GQLClientContext';
import IFrameModal from './IFrameModal';
import EnvironmentVariablesForm from 'pages/org-slug/projects/id/settings/EnvironmentVariablesForm';
import { EnvironmentVariablesFormValues } from 'types/types';
import {
VITE_LACONICD_CHAIN_ID,
VITE_WALLET_IFRAME_URL,
} from 'utils/constants';
import AccountsDropdown from './AccountsDropdown';
type ConfigureDeploymentFormValues = {
option: string;
lrn?: string;
numProviders?: number;
maxPrice?: string;
};
type ConfigureFormValues = ConfigureDeploymentFormValues &
EnvironmentVariablesFormValues;
const DEFAULT_MAX_PRICE = '10000';
const TX_APPROVAL_TIMEOUT_MS = 60000;
const Configure = () => {
const [isLoading, setIsLoading] = useState(false);
const [deployers, setDeployers] = useState<Deployer[]>([]);
const [selectedAccount, setSelectedAccount] = useState<string>();
const [accounts, setAccounts] = useState<string[]>([]);
const [selectedDeployer, setSelectedDeployer] = useState<Deployer>();
const [isPaymentLoading, setIsPaymentLoading] = useState(false);
const [isPaymentDone, setIsPaymentDone] = useState(false);
const [isFrameVisible, setIsFrameVisible] = useState(false);
const [isAccountsDataReceived, setIsAccountsDataReceived] = useState(false);
const [searchParams] = useSearchParams();
const templateId = searchParams.get('templateId');
const queryParams = new URLSearchParams(location.search);
const owner = queryParams.get('owner');
const name = queryParams.get('name');
const defaultBranch = queryParams.get('defaultBranch');
const fullName = queryParams.get('fullName');
const orgSlug = queryParams.get('orgSlug');
const templateOwner = queryParams.get('templateOwner');
const templateRepo = queryParams.get('templateRepo');
const isPrivate = queryParams.get('isPrivate') === 'true';
const navigate = useNavigate();
const { toast, dismiss } = useToast();
const client = useGQLClient();
const methods = useForm<ConfigureFormValues>({
defaultValues: {
option: 'Auction',
maxPrice: DEFAULT_MAX_PRICE,
lrn: '',
numProviders: 1,
variables: [],
},
});
const selectedOption = methods.watch('option');
const isTabletView = useMediaQuery('(min-width: 720px)'); // md:
const buttonSize = isTabletView ? { size: 'lg' as const } : {};
const createProject = async (
data: FieldValues,
envVariables: AddEnvironmentVariableInput[],
senderAddress: string,
txHash: string,
): Promise<string> => {
setIsLoading(true);
let projectId: string | null = null;
try {
let lrn: string | undefined;
let auctionParams: AuctionParams | undefined;
if (data.option === 'LRN') {
lrn = data.lrn;
} else if (data.option === 'Auction') {
auctionParams = {
numProviders: Number(data.numProviders!),
maxPrice: data.maxPrice!.toString(),
};
}
if (templateId) {
const projectData: any = {
templateOwner,
templateRepo,
owner,
name,
isPrivate,
paymentAddress: senderAddress,
txHash,
};
const { addProjectFromTemplate } = await client.addProjectFromTemplate(
orgSlug!,
projectData,
lrn,
auctionParams,
envVariables,
);
projectId = addProjectFromTemplate.id;
} else {
const { addProject } = await client.addProject(
orgSlug!,
{
name: `${owner}-${name}`,
prodBranch: defaultBranch!,
repository: fullName!,
template: 'webapp',
paymentAddress: senderAddress,
txHash,
},
lrn,
auctionParams,
envVariables,
);
projectId = addProject.id;
}
} catch (error) {
console.error('Error creating project:', error);
toast({
id: 'error-creating-project',
title: 'Error creating project',
variant: 'error',
onDismiss: dismiss,
});
} finally {
setIsLoading(false);
}
if (projectId) {
return projectId;
} else {
throw new Error('Project creation failed');
}
};
const verifyTx = async (
senderAddress: string,
txHash: string,
amount: string,
): Promise<boolean> => {
const isValid = await client.verifyTx(
txHash,
`${amount.toString()}alnt`,
senderAddress,
);
return isValid;
};
const handleFormSubmit = useCallback(
async (createFormData: FieldValues) => {
try {
const deployerLrn = createFormData.lrn;
const deployer = deployers.find(
(deployer) => deployer.deployerLrn === deployerLrn,
);
let amount: string;
let senderAddress: string;
let txHash: string | null = null;
if (createFormData.option === 'LRN' && !deployer?.minimumPayment) {
toast({
id: 'no-payment-required',
title: 'No payment required. Deploying app...',
variant: 'info',
onDismiss: dismiss,
});
txHash = '';
senderAddress = '';
} else {
if (!selectedAccount) return;
senderAddress = selectedAccount;
if (createFormData.option === 'LRN') {
amount = deployer?.minimumPayment!;
} else {
amount = (
createFormData.numProviders * createFormData.maxPrice
).toString();
}
const amountToBePaid = amount.replace(/\D/g, '').toString();
txHash = await cosmosSendTokensHandler(senderAddress, amountToBePaid);
if (!txHash) {
toast({
id: 'unsuccessful-tx',
title: 'Transaction rejected',
variant: 'error',
onDismiss: dismiss,
});
setIsFrameVisible(false);
setIsPaymentLoading(false);
throw new Error('Transaction rejected');
}
// Validate transaction hash
const isTxHashValid = await verifyTx(
senderAddress,
txHash,
amountToBePaid,
);
setIsPaymentLoading(false);
if (isTxHashValid) {
toast({
id: 'payment-successful',
title: 'Payment successful',
variant: 'success',
onDismiss: dismiss,
});
setIsPaymentDone(true);
} else {
toast({
id: 'invalid-tx-hash',
title: 'Transaction validation failed',
variant: 'error',
onDismiss: dismiss,
});
throw new Error('Transaction validation failed');
}
}
const environmentVariables = createFormData.variables.map(
(variable: any) => {
return {
key: variable.key,
value: variable.value,
environments: Object.entries(createFormData.environment)
.filter(([, value]) => value === true)
.map(([key]) => key.charAt(0).toUpperCase() + key.slice(1)),
};
},
);
const projectId = await createProject(
createFormData,
environmentVariables,
senderAddress,
txHash!,
);
await client.getEnvironmentVariables(projectId);
if (templateId) {
createFormData.option === 'Auction'
? navigate(
`/${orgSlug}/projects/create/success/${projectId}?isAuction=true`,
)
: navigate(
`/${orgSlug}/projects/create/template/deploy?projectId=${projectId}&templateId=${templateId}`,
);
} else {
createFormData.option === 'Auction'
? navigate(
`/${orgSlug}/projects/create/success/${projectId}?isAuction=true`,
)
: navigate(
`/${orgSlug}/projects/create/deploy?projectId=${projectId}`,
);
}
} catch (error: any) {
toast({
id: 'error-deploying-app',
title: 'Error deploying app',
variant: 'error',
onDismiss: dismiss,
});
throw new Error(error);
}
},
[client, createProject, dismiss, toast],
);
const fetchDeployers = useCallback(async () => {
const res = await client.getDeployers();
setDeployers(res.deployers);
}, [client]);
const onAccountChange = useCallback((account: string) => {
setSelectedAccount(account);
}, []);
const onDeployerChange = useCallback(
(selectedLrn: string) => {
const deployer = deployers.find((d) => d.deployerLrn === selectedLrn);
setSelectedDeployer(deployer);
},
[deployers],
);
const cosmosSendTokensHandler = useCallback(
async (selectedAccount: string, amount: string) => {
if (!selectedAccount) {
throw new Error('Account not selected');
}
const senderAddress = selectedAccount;
const snowballAddress = await client.getAddress();
let timeoutId;
try {
setIsPaymentDone(false);
setIsPaymentLoading(true);
await requestTx(senderAddress, snowballAddress, amount);
const txHash = await new Promise<string | null>((resolve, reject) => {
const handleTxStatus = async (event: MessageEvent) => {
if (event.origin !== VITE_WALLET_IFRAME_URL) return;
if (event.data.type === 'TRANSACTION_RESPONSE') {
const txResponse = event.data.data;
resolve(txResponse);
} else if (event.data.type === 'ERROR') {
console.error('Error from wallet:', event.data.message);
reject(new Error('Transaction failed'));
toast({
id: 'error-transaction',
title: 'Error during transaction',
variant: 'error',
onDismiss: dismiss,
});
}
setIsFrameVisible(false);
window.removeEventListener('message', handleTxStatus);
};
window.addEventListener('message', handleTxStatus);
// Set a timeout, consider unsuccessful after 1 min
timeoutId = setTimeout(() => {
reject(new Error('Transaction timeout'));
window.removeEventListener('message', handleTxStatus);
toast({
id: 'transaction-timeout',
title: 'The transaction request timed out. Please try again',
variant: 'error',
onDismiss: dismiss,
});
setIsFrameVisible(false);
setIsPaymentLoading(false);
}, TX_APPROVAL_TIMEOUT_MS);
});
return txHash;
} catch (error) {
console.error('Error in transaction:', error);
throw new Error('Error in transaction');
} finally {
clearTimeout(timeoutId);
}
},
[client, dismiss, toast],
);
const requestTx = async (
sender: string,
recipient: string,
amount: string,
) => {
const iframe = document.getElementById('walletIframe') as HTMLIFrameElement;
if (!iframe.contentWindow) {
console.error('Iframe not found or not loaded');
throw new Error('Iframe not found or not loaded');
}
iframe.contentWindow.postMessage(
{
type: 'REQUEST_TX',
chainId: VITE_LACONICD_CHAIN_ID,
fromAddress: sender,
toAddress: recipient,
amount,
},
VITE_WALLET_IFRAME_URL,
);
setIsFrameVisible(true);
};
useEffect(() => {
fetchDeployers();
}, []);
return (
<div className="space-y-7 px-4 py-6">
<div className="flex justify-between mb-6">
<div className="space-y-1.5">
<Heading as="h4" className="md:text-lg font-medium">
Configure deployment
</Heading>
<Heading
as="h5"
className="text-sm font-sans text-elements-low-em dark:text-foreground-secondaryu"
>
The app can be deployed by setting the deployer LRN for a single
deployment or by creating a deployer auction for multiple
deployments
</Heading>
</div>
</div>
<div className="flex flex-col gap-6 lg:gap-8 w-full">
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(handleFormSubmit)}>
<div className="flex flex-col justify-start gap-4 mb-6">
<Controller
name="option"
control={methods.control}
render={({ field: { value, onChange } }) => (
<Select
value={value}
onChange={(event) => onChange(event.target.value)}
size="small"
displayEmpty
className="dark:bg-overlay2 dark:text-foreground"
sx={{
fontFamily: 'inherit',
'& .MuiOutlinedInput-notchedOutline': {
borderColor: '#e0e0e0',
borderRadius: '8px',
},
}}
>
<MenuItem value="Auction">Create Auction</MenuItem>
<MenuItem value="LRN">Deployer LRN</MenuItem>
</Select>
)}
/>
</div>
{selectedOption === 'LRN' && (
<div className="flex flex-col justify-start gap-4 mb-6">
<Heading
as="h5"
className="text-sm font-sans text-elements-low-em dark:text-foreground-secondary"
>
The app will be deployed by the configured deployer
</Heading>
<Controller
name="lrn"
control={methods.control}
rules={{ required: true }}
render={({ field: { value, onChange }, fieldState }) => (
<FormControl fullWidth error={Boolean(fieldState.error)}>
<span className="text-sm dark:text-foreground text-elements-high-em dark:text-foreground mb-4">
Select deployer LRN
</span>
<Select
value={value}
onChange={(event) => {
onChange(event.target.value);
onDeployerChange(event.target.value);
}}
displayEmpty
size="small"
className="dark:bg-overlay2 dark:text-foreground"
>
{deployers.map((deployer) => (
<MenuItem
key={deployer.deployerLrn}
value={deployer.deployerLrn}
>
{`${deployer.deployerLrn} ${deployer.minimumPayment ? `(${deployer.minimumPayment})` : ''}`}
</MenuItem>
))}
</Select>
{fieldState.error && (
<FormHelperText>
{fieldState.error.message}
</FormHelperText>
)}
</FormControl>
)}
/>
</div>
)}
{selectedOption === 'Auction' && (
<>
<div className="flex flex-col justify-start gap-4 mb-6">
<Heading
as="h5"
className="text-sm font-sans text-elements-low-em dark:text-foreground-secondary"
>
Set the number of deployers and maximum price for each
deployment
</Heading>
<span className="text-sm text-elements-high-em dark:text-foreground">
Number of Deployers
</span>
<Controller
name="numProviders"
control={methods.control}
rules={{ required: true }}
render={({ field: { value, onChange } }) => (
<Input
type="number"
value={value}
onChange={(e) => onChange(e)}
/>
)}
/>
</div>
<div className="flex flex-col justify-start gap-4 mb-6">
<span className="text-sm text-elements-high-em dark:text-foreground">
Maximum Price (alnt)
</span>
<Controller
name="maxPrice"
control={methods.control}
rules={{ required: true }}
render={({ field: { value, onChange } }) => (
<Input type="number" value={value} onChange={onChange} />
)}
/>
</div>
</>
)}
<Heading as="h4" className="md:text-lg font-medium mb-3">
Environment Variables
</Heading>
<div className="p-4 bg-slate-100 dark:bg-overlay3 rounded-lg mb-6">
<EnvironmentVariablesForm />
</div>
{selectedOption === 'LRN' && !selectedDeployer?.minimumPayment ? (
<div>
<Button
{...buttonSize}
type="submit"
disabled={isLoading || !selectedDeployer || !selectedAccount}
rightIcon={
isLoading ? (
<LoadingIcon className="animate-spin" />
) : (
<ArrowRightCircleFilledIcon />
)
}
>
{isLoading ? 'Deploying' : 'Deploy'}
</Button>
</div>
) : (
<>
<AccountsDropdown
accounts={accounts}
onAccountChange={onAccountChange}
isDataReceived={isAccountsDataReceived}
/>
{accounts.length > 0 && (
<div>
<Button
{...buttonSize}
type="submit"
shape="default"
disabled={
isLoading || isPaymentLoading || !selectedAccount
}
rightIcon={
isLoading || isPaymentLoading ? (
<LoadingIcon className="animate-spin" />
) : (
<ArrowRightCircleFilledIcon />
)
}
>
{!isPaymentDone
? isPaymentLoading
? 'Transaction Requested'
: 'Pay and Deploy'
: isLoading
? 'Deploying'
: 'Deploy'}
</Button>
</div>
)}
</>
)}
</form>
</FormProvider>
<IFrameModal
setAccounts={setAccounts}
setIsDataReceived={setIsAccountsDataReceived}
isVisible={isFrameVisible}
/>
</div>
</div>
);
};
export default Configure;