feat: UI simple create ap view (#191)

* feat: add create app context

* chore: add stepper and nfa picker

* chore: crete step component

* chore: fix create AP text

* chore: update query to list NFAs

* chore: changes PR

* feat: handle error on create AP

* chore: add message error when create AP

* chore: add form context

* Update ui/src/views/access-point/create-ap.form-body.tsx

Co-authored-by: Felipe Mendes <zo.fmendes@gmail.com>

---------

Co-authored-by: Felipe Mendes <zo.fmendes@gmail.com>
This commit is contained in:
Camila Sosa Morales 2023-03-30 15:39:17 -03:00 committed by GitHub
parent ac618f9a32
commit f3f35bb19b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 502 additions and 24 deletions

View File

@ -1,5 +1,5 @@
query lastMintsPaginated($pageSize: Int, $skip: Int) {
newMints(
query lastNFAsPaginated($pageSize: Int, $skip: Int) {
tokens(
first: $pageSize
skip: $skip
orderDirection: desc
@ -18,6 +18,20 @@ query totalTokens {
}
}
query getLatestNFAs {
tokens {
id
name
}
}
query getNFA($id: ID!) {
token(id: $id) {
tokenId
name
}
}
# query to get the ens name of an address
query getENSNames($address: ID!) {
account(id: $address) {

View File

@ -14,6 +14,7 @@ export const App = () => {
<Routes>
<Route path="/" element={<Home />} />
<Route path="/mint" element={<Mint />} />
<Route path="/create-ap" element={<CreateAP />} />
<Route path="/create-ap/:id" element={<CreateAP />} />
{/** TODO remove for release */}
<Route path="/components-test" element={<ComponentsTest />} />

View File

@ -8,8 +8,8 @@ export const ChevronDownIcon: React.FC<IS.CustomProps> = (props) => (
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
fillRule="evenodd"
clipRule="evenodd"
d="M0.96967 0.21967C1.26256 -0.0732233 1.73744 -0.0732233 2.03033 0.21967L6 4.18934L9.96967 0.21967C10.2626 -0.0732233 10.7374 -0.0732233 11.0303 0.21967C11.3232 0.512563 11.3232 0.987437 11.0303 1.28033L6.53033 5.78033C6.23744 6.07322 5.76256 6.07322 5.46967 5.78033L0.96967 1.28033C0.676777 0.987437 0.676777 0.512563 0.96967 0.21967Z"
fill="currentColor"
/>

View File

@ -4,3 +4,4 @@ export * from './form';
export * from './card';
export * from './spinner';
export * from './toast';
export * from './step';

View File

@ -0,0 +1 @@
export * from './step';

View File

@ -31,12 +31,12 @@ const Container = ({ children }: MintStepContainerProps) => (
</Flex>
);
type MintStepProps = {
type StepProps = {
children: React.ReactNode;
header: string;
};
export const MintStep: React.FC<MintStepProps> = ({ children, header }) => {
export const Step: React.FC<StepProps> = ({ children, header }) => {
return (
<Container>
<StepperIndicatorContainer>

View File

@ -179,6 +179,11 @@ export namespace ArgumentsMaps {
boolean // bool accessPointAutoApproval
];
addAccessPoint: [
number, // tokenId
string // access point DNS
];
/**
* TODO: Add other functions arguments as they are needed.
*/

View File

@ -0,0 +1,44 @@
import { Card, Grid, Icon, IconButton, Stepper } from '@/components';
import { CreateAccessPoint } from './create-ap.context';
import { CreateAccessPointFormBody } from './create-ap.form-body';
export const CreateAccessPointForm = () => {
const { prevStep } = Stepper.useContext();
const { nfa } = CreateAccessPoint.useContext();
return (
<Card.Container css={{ width: '$107h' }}>
<Card.Heading
title={`Create Access Point - ${nfa.label || ''}`}
leftIcon={
<IconButton
aria-label="Add"
colorScheme="gray"
variant="link"
icon={<Icon name="back" />}
css={{ mr: '$2' }}
onClick={prevStep}
/>
}
rightIcon={
<IconButton
aria-label="Add"
colorScheme="gray"
variant="link"
icon={<Icon name="info" />}
/>
}
/>
<Card.Body>
<Grid
css={{
rowGap: '$6',
}}
>
<CreateAccessPointFormBody />
</Grid>
</Card.Body>
</Card.Container>
);
};

View File

@ -0,0 +1,113 @@
import {
Button,
Card,
Flex,
Form,
Grid,
Icon,
IconButton,
Stepper,
} from '@/components';
import { useTransactionCost } from '@/hooks';
import { FleekERC721 } from '@/integrations';
import { useMemo } from 'react';
import { ethers } from 'ethers';
import { CreateAccessPoint } from './create-ap.context';
import { useAccessPointFormContext } from './create-ap.form.context';
export const CreateAccessPointPreview = () => {
const { prevStep } = Stepper.useContext();
const {
prepare: { status: prepareStatus, data: prepareData, error: prepareError },
write: { status: writeStatus, write },
transaction: { status: transactionStatus },
} = CreateAccessPoint.useTransactionContext();
const {
form: {
appName: {
value: [appName],
},
},
} = useAccessPointFormContext();
const { nfa } = CreateAccessPoint.useContext();
const [cost, currency, isCostLoading] = useTransactionCost(
prepareData?.request.value,
prepareData?.request.gasLimit
);
const message = useMemo(() => {
if (isCostLoading || prepareStatus === 'loading')
return 'Calculating cost...';
if (prepareError) {
const parsedError = FleekERC721.parseError(
(prepareError as any).error?.data.data
);
if (parsedError.isIdentified) {
return parsedError.message;
}
return 'An error occurred while preparing the transaction';
}
const formattedCost = ethers.utils.formatEther(cost).slice(0, 9);
return `Creating this Access Point will cost ${formattedCost} ${currency}.`;
}, [prepareData, isCostLoading, prepareStatus]);
const isLoading = useMemo(
() =>
[prepareStatus, writeStatus, transactionStatus].some(
(status) => status === 'loading'
),
[prepareStatus, writeStatus, transactionStatus]
);
return (
<Card.Container css={{ width: '$107h' }}>
<Card.Heading
title={`Create Access Point ${nfa.label || ''}`}
leftIcon={
<IconButton
aria-label="Add"
colorScheme="gray"
variant="link"
icon={<Icon name="back" />}
css={{ mr: '$2' }}
onClick={prevStep}
/>
}
rightIcon={
<IconButton
aria-label="Add"
colorScheme="gray"
variant="link"
icon={<Icon name="info" />}
/>
}
/>
<Card.Body>
<Grid
css={{
rowGap: '$6',
}}
>
<Flex>
<span>NFA: {nfa.value}</span>
<span>{appName}</span>
<span className="text-slate11 text-sm">{message}</span>
</Flex>
<Button
disabled={!!prepareError || !nfa}
colorScheme="blue"
variant="solid"
onClick={write}
isLoading={isLoading}
>
Create
</Button>
</Grid>
</Card.Body>
</Card.Container>
);
};

View File

@ -0,0 +1,67 @@
import { ComboboxItem } from '@/components';
import { EthereumHooks } from '@/integrations';
import { useFleekERC721Billing } from '@/store';
import { AppLog, createContext, pushToast } from '@/utils';
import { useState } from 'react';
export type AccessPointContext = {
billing: string | undefined;
nfa: ComboboxItem;
setNfa: (nfa: ComboboxItem) => void;
};
const [CreateAPProvider, useContext] = createContext<AccessPointContext>({
name: 'CreateAPProvider.Context',
hookName: 'CreateAPProvider.useContext',
providerName: 'CreateAPProvider.Provider',
});
const [TransactionProvider, useTransactionContext] =
EthereumHooks.createFleekERC721WriteContext('addAccessPoint');
export abstract class CreateAccessPoint {
static readonly useContext = useContext;
static readonly useTransactionContext = useTransactionContext;
static readonly Provider: React.FC<CreateAccessPoint.ProviderProps> = ({
children,
}) => {
const [billing] = useFleekERC721Billing('AddAccessPoint');
const [nfa, setNfa] = useState<ComboboxItem>({} as ComboboxItem);
const value = {
billing,
nfa,
setNfa,
};
return (
<CreateAPProvider value={value}>
<TransactionProvider
config={{
transaction: {
onSuccess: (data) => {
AppLog.info('Transaction:', data);
pushToast('success', 'Your transaction was successful!');
},
onError: (error) => {
AppLog.errorToast(
'There was an error trying to create the Access Point. Please try again'
);
},
},
}}
>
{children}
</TransactionProvider>
</CreateAPProvider>
);
};
}
export namespace CreateAccessPoint {
export type ProviderProps = {
children: React.ReactNode;
};
}

View File

@ -0,0 +1,98 @@
import { Button, Flex, Form, Spinner, Stepper } from '@/components';
import { AppLog } from '@/utils';
import { useQuery } from '@apollo/client';
import { ethers } from 'ethers';
import { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { useAccessPointFormContext } from './create-ap.form.context';
import { NfaPicker } from './nfa-picker';
import { getNFADocument } from '@/graphclient';
import { CreateAccessPoint } from './create-ap.context';
export const CreateAccessPointFormBody = () => {
const { id } = useParams();
const { nextStep } = Stepper.useContext();
const { nfa, setNfa, billing } = CreateAccessPoint.useContext();
const { setArgs } = CreateAccessPoint.useTransactionContext();
const {
form: {
appName: {
value: [appName],
},
isValid: [isValid],
},
} = useAccessPointFormContext();
const {
form: { appName: appNameContext },
} = useAccessPointFormContext();
const {
data: nfaData,
error: nfaError,
loading: nfaLoading,
} = useQuery(getNFADocument, {
skip: id === undefined,
variables: {
id: ethers.utils.hexlify(Number(id)),
},
});
useEffect(() => {
if (nfaError) {
AppLog.errorToast('Error fetching NFA');
}
}, [nfaError]);
useEffect(() => {
if (nfaData) {
if (nfaData.token && id) {
const { name } = nfaData.token;
setNfa({ value: id, label: name });
} else {
AppLog.errorToast("We couldn't find the NFA you are looking for");
}
}
}, [nfaData, id]);
if (nfaLoading) {
return (
<Flex
css={{
justifyContent: 'center',
alignItems: 'center',
height: '$48',
}}
>
<Spinner />
</Flex>
);
}
const handleContinueClick = () => {
if (nfa && appName) {
setArgs([Number(nfa.value), appName, { value: billing }]);
nextStep();
}
};
return (
<>
{/* TODO will have to do some changes on the Form.Combobox if we use this component for the NFA picker */}
{id === undefined && <NfaPicker />}
<Form.Field context={appNameContext}>
<Form.Label>App Name</Form.Label>
<Form.Input />
</Form.Field>
<Button
disabled={!isValid}
colorScheme="blue"
variant="solid"
onClick={handleContinueClick}
>
Continue
</Button>
</>
);
};

View File

@ -0,0 +1,28 @@
import { FormField, useFormField } from '@/components';
import { createContext, StringValidators } from '@/utils';
import { useState } from 'react';
export type CreateAccessPointFormContext = {
form: {
appName: FormField;
isValid: ReactState<boolean>;
};
};
export const [CreateAccessPointFormProvider, useAccessPointFormContext] =
createContext<CreateAccessPointFormContext>({
name: 'MintFormContext',
hookName: 'useMintFormContext',
providerName: 'MintFormProvider',
});
export const useAccessPointFormContextInit =
(): CreateAccessPointFormContext => ({
form: {
appName: useFormField('appName', [
StringValidators.required,
StringValidators.maxLength(50),
]),
isValid: useState(false),
},
});

View File

@ -0,0 +1,38 @@
import { Form, Step, Stepper } from '@/components';
import { WalletStep } from '../mint/wallet-step';
import { CreateAccessPointForm } from './create-ap-form';
import { CreateAccessPointPreview } from './create-ap-preview';
import { useAccessPointFormContext } from './create-ap.form.context';
export const CreateApStepper = () => {
const {
form: {
isValid: [, setIsValid],
},
} = useAccessPointFormContext();
return (
<Stepper.Root initialStep={1}>
<Form.Root onValidationChange={setIsValid}>
<Stepper.Container>
<Stepper.Step>
<Step header="Connect your Ethereum Wallet to create Access Point">
<WalletStep />
</Step>
</Stepper.Step>
<Stepper.Step>
<Step header="Set Access Point">
<CreateAccessPointForm />
</Step>
</Stepper.Step>
<Stepper.Step>
<Step header="Create Access Point">
<CreateAccessPointPreview />
</Step>
</Stepper.Step>
</Stepper.Container>
</Form.Root>
</Stepper.Root>
);
};

View File

@ -1,6 +1,27 @@
import { useParams } from 'react-router-dom';
import { Flex } from '@/components';
import { CreateAccessPoint } from './create-ap.context';
import {
CreateAccessPointFormProvider,
useAccessPointFormContextInit,
} from './create-ap.form.context';
import { CreateApStepper } from './create-ap.stepper';
export const CreateAP = () => {
const { id } = useParams();
return <>Token to create AP:{id}</>;
const context = useAccessPointFormContextInit();
return (
<Flex
css={{
height: '100%',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
}}
>
<CreateAccessPoint.Provider>
<CreateAccessPointFormProvider value={context}>
<CreateApStepper />
</CreateAccessPointFormProvider>
</CreateAccessPoint.Provider>
</Flex>
);
};

View File

@ -0,0 +1,33 @@
import { Combobox, ComboboxItem } from '@/components';
import { getLatestNFAsDocument } from '@/graphclient';
import { AppLog } from '@/utils';
import { useQuery } from '@apollo/client';
import { useMemo } from 'react';
import { CreateAccessPoint } from './create-ap.context';
export const NfaPicker = () => {
const { nfa, setNfa } = CreateAccessPoint.useContext();
const { data, loading, error } = useQuery(getLatestNFAsDocument);
const handleNfaChange = (item: ComboboxItem) => {
setNfa(item);
};
const items = useMemo(() => {
return data
? data.tokens.map(
(nfa) => ({ value: nfa.id, label: nfa.name } as ComboboxItem)
)
: [];
}, [data]);
if (loading) return <div>Loading...</div>;
if (error) {
AppLog.errorToast('Error loading NFA list');
}
return (
<Combobox items={items} selectedValue={nfa} onChange={handleNfaChange} />
);
};

View File

@ -4,7 +4,7 @@ import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { Button, Card, Flex, NoResults } from '@/components';
import { lastMintsPaginatedDocument, totalTokensDocument } from '@/graphclient';
import { lastNFAsPaginatedDocument, totalTokensDocument } from '@/graphclient';
import { FleekERC721 } from '@/integrations/ethereum/contracts';
const pageSize = 10; //Set this size to test pagination
@ -23,7 +23,7 @@ export const NFAList = () => {
data: dataMintedTokens,
loading: loadingMintedTokens,
error: errorMintedTokens,
} = useQuery(lastMintsPaginatedDocument, {
} = useQuery(lastNFAsPaginatedDocument, {
variables: {
//first page is 0
pageSize,
@ -68,8 +68,8 @@ export const NFAList = () => {
</Button>
</Flex>
<div>
{dataMintedTokens && dataMintedTokens.newMints.length > 0 ? (
dataMintedTokens.newMints.map((mint) => (
{dataMintedTokens && dataMintedTokens.tokens.length > 0 ? (
dataMintedTokens.tokens.map((mint) => (
<Card.Container
key={mint.tokenId}
css={{ display: 'inline-block', m: '$2' }}

View File

@ -1,7 +1,6 @@
import { Form, Stepper } from '@/components';
import { Form, Stepper, Step } from '@/components';
import { MintPreview } from './preview-step/mint-preview';
import { GithubStep } from './github-step';
import { MintStep } from './mint-step';
import { WalletStep } from './wallet-step';
import { NFAStep } from './nfa-step';
import { Mint } from './mint.context';
@ -24,27 +23,27 @@ export const MintStepper = () => {
<Form.Root onValidationChange={setIsValid}>
<Stepper.Container>
<Stepper.Step>
<MintStep header="Connect your Ethereum Wallet to mint an NFA">
<Step header="Connect your Ethereum Wallet to mint an NFA">
<WalletStep />
</MintStep>
</Step>
</Stepper.Step>
<Stepper.Step>
<MintStep header="Connect GitHub and select repository">
<Step header="Connect GitHub and select repository">
<GithubStep />
</MintStep>
</Step>
</Stepper.Step>
<Stepper.Step>
<MintStep header="Finalize a few key things for your NFA">
<Step header="Finalize a few key things for your NFA">
<NFAStep />
</MintStep>
</Step>
</Stepper.Step>
<Stepper.Step>
<MintStep header="Review your NFA and mint it on Polygon">
<Step header="Review your NFA and mint it on Polygon">
<MintPreview />
</MintStep>
</Step>
</Stepper.Step>
</Stepper.Container>
</Form.Root>

View File

@ -1,9 +1,10 @@
import { Icon, IconButton, Stepper } from '@/components';
import { useTransactionCost } from '@/hooks';
import { FleekERC721 } from '@/integrations';
import { AppLog } from '@/utils';
import { Mint } from '@/views/mint/mint.context';
import { ethers } from 'ethers';
import { useMemo } from 'react';
import { useEffect, useMemo } from 'react';
import { NftCard } from '../nft-card';
export const MintPreview = () => {
@ -49,6 +50,20 @@ export const MintPreview = () => {
[prepareStatus, writeStatus, transactionStatus]
);
const error = useMemo(
() =>
[prepareStatus, writeStatus, transactionStatus].some(
(status) => status === 'error'
),
[prepareStatus, writeStatus, transactionStatus]
);
useEffect(() => {
if (error) {
AppLog.errorToast('An error occurred while minting the NFA');
}
}, [error]);
return (
<NftCard
title="Mint NFA"