From ba0dd135b91b4fa1bec7c3401b7c86c079b045a2 Mon Sep 17 00:00:00 2001 From: yuetloo Date: Tue, 26 Jul 2022 00:47:25 -0400 Subject: [PATCH] add metadata validation and code cleanup --- vue-app/src/App.vue | 1 + vue-app/src/api/metadata.ts | 123 ++++---- vue-app/src/api/receiving-address.ts | 36 +++ .../src/api/recipient-registry-optimistic.ts | 32 +- vue-app/src/api/recipient.ts | 31 -- .../components/MetadataSubmissionWidget.vue | 43 ++- vue-app/src/components/TransactionResult.vue | 6 +- vue-app/src/router/index.ts | 9 + vue-app/src/views/JoinOptimistic.vue | 64 ++-- vue-app/src/views/MetadataForm.vue | 146 +++------ .../src/views/MetadataTransactionSuccess.vue | 174 +++++++++++ vue-app/src/views/MetadataViewer.vue | 277 +++++++++++------- 12 files changed, 554 insertions(+), 388 deletions(-) create mode 100644 vue-app/src/api/receiving-address.ts create mode 100644 vue-app/src/views/MetadataTransactionSuccess.vue diff --git a/vue-app/src/App.vue b/vue-app/src/App.vue index a4ba66248..4c2d1a60f 100644 --- a/vue-app/src/App.vue +++ b/vue-app/src/App.vue @@ -177,6 +177,7 @@ export default class App extends Vue { 'join-step', 'round-information', 'transaction-success', + 'metadata-success', 'verify', 'verify-step', 'verified', diff --git a/vue-app/src/api/metadata.ts b/vue-app/src/api/metadata.ts index fa0563d13..c99bf3d3e 100644 --- a/vue-app/src/api/metadata.ts +++ b/vue-app/src/api/metadata.ts @@ -4,7 +4,9 @@ import { METADATA_NETWORKS, METADATA_SUBGRAPH_URL_PREFIX, chain } from './core' import { Project } from './projects' import { Ipfs } from './ipfs' import { MAX_RETRIES } from './core' -import { required, url } from 'vuelidate/lib/validators' +import { required, url, maxLength } from 'vuelidate/lib/validators' +import * as isIPFS from 'is-ipfs' +import { ReceivingAddress } from '@/api/receiving-address' const subgraphUrl = (network: string): string => `${METADATA_SUBGRAPH_URL_PREFIX}${network}` @@ -40,6 +42,7 @@ export interface MetadataFormData { } fund: { receivingAddresses: string[] + currentChainReceivingAddress: string plans: string } team: { @@ -65,39 +68,56 @@ export interface MetadataFormData { owner?: string } -export const MetadataValidations = { - id: { required }, - name: { required }, - tagline: { required }, - description: { required }, - category: { required }, - problemSpace: { required }, - githubUrl: { url }, - radicleUrl: { url }, - websiteUrl: { url }, - twitterUrl: { url }, - discordUrl: { url }, - bannerImageHash: { required }, - thumbnailImageHash: { required }, +export const MetadataFormValidations = { + project: { + name: { required }, + tagline: { + required, + maxLength: maxLength(140), + }, + description: { required }, + category: { required }, + problemSpace: { required }, + }, + fund: { + receivingAddresses: {}, + currentChainReceivingAddress: { + required, + validEthAddress: utils.isAddress, + }, + plans: { required }, + }, + team: { + name: {}, + description: {}, + }, + links: { + github: { url }, + radicle: { url }, + website: { url }, + twitter: { url }, + discord: { url }, + }, + image: { + bannerHash: { + required, + validIpfsHash: isIPFS.cid, + }, + thumbnailHash: { + required, + validIpfsHash: isIPFS.cid, + }, + }, } /** - * Extract address for the given chain + * Extract address for the current chain from the fund receiving addresses * @param receivingAddresses array of EIP-3770 addresses, i.e. eth:0x11111... - * @param chainShortName chain short name - * @returns address for the chain + * @returns address for the current chain */ -function getAddressForChain( - receivingAddresses: string[] = [], - chainShortName: string -): string { - const chainAddresses = receivingAddresses.reduce((addresses, data) => { - const [chainName, address] = data.split(':') - addresses[chainName] = address - return addresses - }, {}) - - return chainAddresses[chainShortName] +function getAddressForCurrentChain(receivingAddresses: string[] = []): string { + const addresses = ReceivingAddress.fromArray(receivingAddresses) + return addresses[chain.shortName] } /** @@ -121,23 +141,6 @@ async function getLatestBlock(network: string): Promise { return meta.block.number } -/** - * Parse and populate receiving addresses - * @param data data containing receivingAddresses - * @returns metadata populated with resolvedAddress and addressName - */ -async function populateAddresses(data: any): Promise { - const addressName = getAddressForChain( - data.receivingAddresses, - chain.shortName - ) - - return { - ...data, - addressName, - } -} - function sleep(factor: number): Promise { const timeout = factor ** 2 * 1000 return new Promise((resolve) => setTimeout(resolve, timeout)) @@ -152,8 +155,7 @@ export class Metadata { owner?: string network?: string receivingAddresses?: string[] - addressName?: string - resolvedAddress?: string + currentChainReceivingAddress?: string tagline?: string description?: string category?: string @@ -179,6 +181,9 @@ export class Metadata { this.owner = data.owner this.network = data.network this.receivingAddresses = data.receivingAddresses + this.currentChainReceivingAddress = getAddressForCurrentChain( + data.receivingAddresses + ) this.tagline = data.tagline this.description = data.description this.category = data.category @@ -233,8 +238,7 @@ export class Metadata { return null } - const arg = await populateAddresses(data) - return new Metadata({ ...arg }) + return new Metadata({ ...data }) } /** @@ -252,29 +256,14 @@ export class Metadata { return result.data?.metadataEntries || [] } - /** - * get the receiving address of the current chain - * @param addresses list of EIP3770 addresses - * @returns the address of the current chain - */ - getCurrentChainAddress(addresses: string[] = []): string { - const chainPrefix = chain.shortName + ':' - const chainAddress = addresses.find((addr) => { - return addr.startsWith(chainPrefix) - }) - - return chainAddress ? chainAddress.substring(chainPrefix.length) : '' - } - /** * Convert metadata to project interface * @returns project */ toProject(): Project { - const address = this.getCurrentChainAddress(this.receivingAddresses) return { id: this.id || '', - address, + address: this.currentChainReceivingAddress || '', name: this.name || '', tagline: this.tagline, description: this.description || '', @@ -312,6 +301,8 @@ export class Metadata { }, fund: { receivingAddresses: this.receivingAddresses || [], + currentChainReceivingAddress: + getAddressForCurrentChain(this.receivingAddresses) || '', plans: this.plans || '', }, team: { diff --git a/vue-app/src/api/receiving-address.ts b/vue-app/src/api/receiving-address.ts new file mode 100644 index 000000000..6f12d0424 --- /dev/null +++ b/vue-app/src/api/receiving-address.ts @@ -0,0 +1,36 @@ +/** + * Fund Receiving Address + */ +export class ReceivingAddress { + /** + * Convert the receiving addresses from string to a lookup dictionary + * @param addresses array of EIP3770 addresses (e.g. eth:0x1234...) + * @returns a dictionary of chain short name to address + */ + static fromArray(addresses: string[]): Record { + const result: Record = addresses.reduce( + (addresses, item) => { + const chainAddress = item.split(':') + + if (chainAddress.length === 2) { + addresses[chainAddress[0]] = chainAddress[1] + } + return addresses + }, + {} + ) + + return result + } + + /** + * Convert a chain-address dictionary to an array of EIP3770 addresses + * @param addresses a dictionary with chain short name to address + * @returns an array of EIP3770 addresses + */ + static toArray(addresses: Record): string[] { + return Object.entries(addresses).map( + ([chain, address]) => `${chain}:${address}` + ) + } +} diff --git a/vue-app/src/api/recipient-registry-optimistic.ts b/vue-app/src/api/recipient-registry-optimistic.ts index 671dd56df..0499caf2c 100644 --- a/vue-app/src/api/recipient-registry-optimistic.ts +++ b/vue-app/src/api/recipient-registry-optimistic.ts @@ -6,9 +6,8 @@ import { import { getEventArg } from '@/utils/contracts' import { OptimisticRecipientRegistry } from './abi' -import { RecipientApplicationData } from './recipient' import { RecipientRegistryInterface } from './types' -import { Metadata } from './metadata' +import { MetadataFormData } from './metadata' import { chain } from './core' // TODO merge this with `Project` inteface @@ -33,33 +32,9 @@ export interface RecipientData { thumbnailImageHash?: string } -export function formToRecipientData( - data: RecipientApplicationData -): RecipientData { - const { project, fund, team, links, image } = data - return { - address: fund.resolvedAddress, - name: project.name, - tagline: project.tagline, - description: project.description, - category: project.category, - problemSpace: project.problemSpace, - plans: fund.plans, - teamName: team.name, - teamDescription: team.description, - githubUrl: links.github, - radicleUrl: links.radicle, - websiteUrl: links.website, - twitterUrl: links.twitter, - discordUrl: links.discord, - bannerImageHash: image.bannerHash, - thumbnailImageHash: image.thumbnailHash, - } -} - export async function addRecipient( registryAddress: string, - recipientMetadata: Metadata, + recipientMetadata: MetadataFormData, deposit: BigNumber, signer: Signer ): Promise { @@ -68,11 +43,12 @@ export async function addRecipient( OptimisticRecipientRegistry, signer ) - const { id, address } = recipientMetadata.toProject() + const { id, fund } = recipientMetadata if (!id) { throw new Error('Missing metadata id') } + const { currentChainReceivingAddress: address } = fund if (!address) { throw new Error(`Missing recipient address for the ${chain.name} network`) } diff --git a/vue-app/src/api/recipient.ts b/vue-app/src/api/recipient.ts index 951e5893b..d001923b2 100644 --- a/vue-app/src/api/recipient.ts +++ b/vue-app/src/api/recipient.ts @@ -1,6 +1,3 @@ -import { Project } from './projects' -import { ipfsGatewayUrl } from './core' - export interface RecipientApplicationData { project: { name: string @@ -35,31 +32,3 @@ export interface RecipientApplicationData { hasEns: boolean id?: string } - -export function formToProjectInterface( - data: RecipientApplicationData -): Project { - const { project, fund, team, links, image } = data - return { - id: fund.resolvedAddress, - address: fund.resolvedAddress, - name: project.name, - tagline: project.tagline, - description: project.description, - category: project.category, - problemSpace: project.problemSpace, - plans: fund.plans, - teamName: team.name, - teamDescription: team.description, - githubUrl: links.github, - radicleUrl: links.radicle, - websiteUrl: links.website, - twitterUrl: links.twitter, - discordUrl: links.discord, - bannerImageUrl: `${ipfsGatewayUrl}/ipfs/${image.bannerHash}`, - thumbnailImageUrl: `${ipfsGatewayUrl}/ipfs/${image.thumbnailHash}`, - index: 0, - isHidden: false, - isLocked: true, - } -} diff --git a/vue-app/src/components/MetadataSubmissionWidget.vue b/vue-app/src/components/MetadataSubmissionWidget.vue index d2e8dc871..f06117839 100644 --- a/vue-app/src/components/MetadataSubmissionWidget.vue +++ b/vue-app/src/components/MetadataSubmissionWidget.vue @@ -3,8 +3,11 @@
-
- Waiting for block {{ progress.current }} of {{ progress.last }}... +
+ +
Check your wallet for a prompt...
@@ -16,12 +19,27 @@
-
- -
+ +
+
@@ -29,7 +47,6 @@ import Vue from 'vue' import Component from 'vue-class-component' import { Prop } from 'vue-property-decorator' -import { MetadataFormData } from '@/api/metadata' import { User } from '@/api/user' import { chain, TransactionProgress } from '@/api/core' @@ -37,8 +54,6 @@ import Loader from '@/components/Loader.vue' import Transaction from '@/components/Transaction.vue' import WalletWidget from '@/components/WalletWidget.vue' -import { ContractTransaction, ContractReceipt } from '@ethersproject/contracts' - @Component({ components: { Loader, @@ -47,17 +62,12 @@ import { ContractTransaction, ContractReceipt } from '@ethersproject/contracts' }, }) export default class MetadataSubmissionWidget extends Vue { - @Prop() buttonLabel!: string - @Prop() form!: MetadataFormData - @Prop() onSubmit!: ( - form: MetadataFormData, - provider: any - ) => Promise - @Prop() onSuccess!: (receipt: ContractReceipt, chainId: number) => void @Prop() isWaiting!: boolean @Prop({ default: null }) progress!: TransactionProgress | null @Prop({ default: '' }) txHash!: string @Prop({ default: '' }) txError!: string + @Prop({ default: null }) buttonHandler!: () => void + @Prop({ default: false }) disableButton!: boolean get currentUser(): User | null { return this.$store.state.currentUser @@ -70,6 +80,9 @@ export default class MetadataSubmissionWidget extends Vue { get hasTxError(): boolean { return !!this.txError } + get showButton(): boolean { + return !!this.buttonHandler + } } diff --git a/vue-app/src/components/TransactionResult.vue b/vue-app/src/components/TransactionResult.vue index 6a94933c3..0800ddab4 100644 --- a/vue-app/src/components/TransactionResult.vue +++ b/vue-app/src/components/TransactionResult.vue @@ -20,8 +20,9 @@ import Vue from 'vue' import Component from 'vue-class-component' import { Prop } from 'vue-property-decorator' -import { ChainInfo, CHAIN_INFO, ChainId } from '@/plugins/Web3/constants/chains' +import { ChainInfo } from '@/plugins/Web3/constants/chains' import { LinkInfo } from '@/api/types' +import { chain } from '@/api/core' import TransactionReceipt from '@/components/TransactionReceipt.vue' import Links from '@/components/Links.vue' import ImageResponsive from '@/components/ImageResponsive.vue' @@ -35,11 +36,10 @@ import ImageResponsive from '@/components/ImageResponsive.vue' }) export default class TransactionResult extends Vue { @Prop() hash!: string - @Prop() chainId!: ChainId @Prop() buttons!: LinkInfo[] get chain(): ChainInfo { - return CHAIN_INFO[this.chainId] + return chain } } diff --git a/vue-app/src/router/index.ts b/vue-app/src/router/index.ts index 124b763f2..3573a74b9 100644 --- a/vue-app/src/router/index.ts +++ b/vue-app/src/router/index.ts @@ -29,6 +29,7 @@ import MetadataDetail from '@/views/Metadata.vue' import MetadataRegistry from '@/views/MetadataRegistry.vue' import MetadataFormAdd from '@/views/MetadataFormAdd.vue' import MetadataFormEdit from '@/views/MetadataFormEdit.vue' +import MetadataTransactionSuccess from '@/views/MetadataTransactionSuccess.vue' import NotFound from '@/views/NotFound.vue' Vue.use(VueRouter) @@ -237,6 +238,14 @@ const routes = [ title: 'Transaction Success', }, }, + { + path: '/metadata-success/:hash/:id', + name: 'metadata-success', + component: MetadataTransactionSuccess, + meta: { + title: 'Metadata Transaction Success', + }, + }, { path: '/metadata', name: 'metadata-registry', diff --git a/vue-app/src/views/JoinOptimistic.vue b/vue-app/src/views/JoinOptimistic.vue index 38a641cc8..b54beb437 100644 --- a/vue-app/src/views/JoinOptimistic.vue +++ b/vue-app/src/views/JoinOptimistic.vue @@ -113,7 +113,7 @@ import { } from '@/store/mutation-types' import { Project } from '@/api/projects' -import { Metadata, MetadataValidations } from '@/api/metadata' +import { Metadata, MetadataFormValidations } from '@/api/metadata' import { DateTime } from 'luxon' import { addRecipient } from '@/api/recipient-registry-optimistic' import { waitForTransaction } from '@/utils/contracts' @@ -132,7 +132,7 @@ import { required, email } from 'vuelidate/lib/validators' Loader, }, validations: { - metadata: MetadataValidations, + form: MetadataFormValidations, email: { email, required: process.env.VUE_APP_GOOGLE_SPREADSHEET_ID @@ -149,7 +149,7 @@ export default class JoinView extends mixins(validationMixin) { stepNames = this.isEmailRequired ? ['Select a project metadata', 'Review', 'Email', 'Submit'] : ['Select a project metadata', 'Review', 'Submit'] - metadata = new Metadata({ furtherstep: 0 }) + form = new Metadata({ furtherstep: 0 }).toFormData() email = '' loading = true isWaiting = false @@ -179,19 +179,19 @@ export default class JoinView extends mixins(validationMixin) { } } + get metadata(): Metadata { + return Metadata.fromFormData(this.form) + } + + // Check that at least one link is not empty && no links are invalid isLinkStepValid(): boolean { let isValid = false - const links = [ - this.$v.metadata.githubUrl, - this.$v.metadata.radicleUrl, - this.$v.metadata.websiteUrl, - this.$v.metadata.twitterUrl, - this.$v.metadata.discordUrl, - ] - - for (const link of links.filter(Boolean)) { - const isInvalid = link?.$invalid - const isEmpty = link?.$model && link?.$model.length === 0 + const links = Object.keys(this.form.links) + for (const link of links) { + const linkData = this.$v.form.links?.[link] + if (!linkData) return false + const isInvalid = linkData.$invalid + const isEmpty = linkData.$model.length === 0 if (isInvalid) { return false } else if (!isEmpty) { @@ -202,10 +202,14 @@ export default class JoinView extends mixins(validationMixin) { } isStepValid(step: number): boolean { + if (this.isWaiting) { + return false + } + let isValid = true const stepName = this.steps[step] if (stepName === 'summary') { - isValid = this.isLinkStepValid() && !this.$v.metadata.$invalid + isValid = this.isLinkStepValid() && !this.$v.form.$invalid } else if (stepName === 'email') { isValid = !this.$v.email.$invalid } @@ -219,15 +223,15 @@ export default class JoinView extends mixins(validationMixin) { saveFormData(updateFurthest?: boolean): void { if (updateFurthest && this.currentStep + 1 > this.furthestStep) { - this.metadata.furthestStep = this.currentStep + 1 + this.form.furthestStep = this.currentStep + 1 } this.$store.commit(SET_RECIPIENT_DATA, { - updatedData: this.metadata, + updatedData: this.form, }) } get furthestStep(): number { - return this.metadata.furthestStep || 0 + return this.form.furthestStep || 0 } get isEmailRequired(): boolean { @@ -237,17 +241,17 @@ export default class JoinView extends mixins(validationMixin) { get isNavDisabled(): boolean { return ( !this.isStepValid(this.currentStep) && - this.currentStep !== this.metadata.furthestStep + this.currentStep !== this.form.furthestStep ) } get hasMetadata(): boolean { - return !!this.metadata.id + return !!this.form.id } handleMetadataSelected(metadata: Metadata): void { - this.metadata = metadata - const id = this.metadata.id || '' + this.form = metadata.toFormData() + const id = metadata.id || '' this.saveFormData(true) this.$router.push({ name: 'join-step', @@ -261,8 +265,8 @@ export default class JoinView extends mixins(validationMixin) { if (this.steps[step] === 'email') { // save the email in metadata for later use - this.metadata.email = this.email - this.$v.metadata.$touch() + this.form.team.email = this.email + this.$v.form.$touch() } // Save form data @@ -273,7 +277,7 @@ export default class JoinView extends mixins(validationMixin) { } else { // Navigate if (this.isStepUnlocked(step)) { - const id = this.metadata.id || '' + const id = this.form.id || '' this.$router.push({ name: 'join-step', params: { @@ -290,16 +294,16 @@ export default class JoinView extends mixins(validationMixin) { if (id) { if (id === this.$store.state.recipient?.id) { - this.metadata = new Metadata(this.$store.state.recipient) - if (this.metadata.email) { - this.email = this.metadata.email + this.form = this.$store.state.recipient + if (this.form.team.email) { + this.email = this.form.team.email this.$v.email.$touch() } } else { try { const metadata = await Metadata.get(id) if (metadata) { - this.metadata = metadata + this.form = metadata.toFormData() this.saveFormData(true) } } catch (err) { @@ -353,7 +357,7 @@ export default class JoinView extends mixins(validationMixin) { headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify(recipient.toFormData()), + body: JSON.stringify(recipient), }) } this.$store.commit(RESET_RECIPIENT_DATA) diff --git a/vue-app/src/views/MetadataForm.vue b/vue-app/src/views/MetadataForm.vue index eee61046c..0ed4d443c 100644 --- a/vue-app/src/views/MetadataForm.vue +++ b/vue-app/src/views/MetadataForm.vue @@ -225,18 +225,20 @@

Enter a valid Ethereum 0x address @@ -480,17 +482,7 @@ blockchain.

- import Component, { mixins } from 'vue-class-component' import { validationMixin } from 'vuelidate' -import { required, minLength, maxLength, url } from 'vuelidate/lib/validators' -import * as isIPFS from 'is-ipfs' -import { isAddress } from '@ethersproject/address' import LayoutSteps from '@/components/LayoutSteps.vue' import ProgressBar from '@/components/ProgressBar.vue' import FormNavigation from '@/components/FormNavigation.vue' @@ -537,15 +526,19 @@ import TransactionResult from '@/components/TransactionResult.vue' import MetadataList from '@/views/MetadataList.vue' import MetadataViewer from '@/views/MetadataViewer.vue' import Dropdown from '@/components/Dropdown.vue' -import { Metadata, MetadataFormData } from '@/api/metadata' +import { + Metadata, + MetadataFormData, + MetadataFormValidations, +} from '@/api/metadata' import { LinkInfo } from '@/api/types' import { ReceivingAddress } from '@/api/receiving-address' import { Prop, Watch } from 'vue-property-decorator' import { chain, TransactionProgress } from '@/api/core' -import { SET_METADATA } from '@/store/mutation-types' +import { SET_METADATA, RESET_RECIPIENT_DATA } from '@/store/mutation-types' import { Project, projectExists } from '@/api/projects' -import { ContractReceipt, ContractTransaction } from 'ethers' +import { ContractTransaction } from 'ethers' import { waitForTransaction } from '@/utils/contracts' @Component({ @@ -568,48 +561,7 @@ import { waitForTransaction } from '@/utils/contracts' TransactionResult, }, validations: { - receivingAddress: { required, validEthAddress: isAddress }, - form: { - project: { - name: { required }, - tagline: { - required, - maxLength: maxLength(140), - }, - description: { required }, - category: { required }, - problemSpace: { required }, - }, - fund: { - receivingAddresses: { - required, - minLength: minLength(1), - }, - resolvedAddress: {}, - plans: { required }, - }, - team: { - name: {}, - description: {}, - }, - links: { - github: { url }, - radicle: { url }, - website: { url }, - twitter: { url }, - discord: { url }, - }, - image: { - bannerHash: { - required, - validIpfsHash: isIPFS.cid, - }, - thumbnailHash: { - required, - validIpfsHash: isIPFS.cid, - }, - }, - }, + form: MetadataFormValidations, }, }) export default class MetadataForm extends mixins(validationMixin) { @@ -622,7 +574,7 @@ export default class MetadataForm extends mixins(validationMixin) { provider: any ) => Promise - form: MetadataFormData = new Metadata({}).toFormData() + form = new Metadata({}).toFormData() projectExists = false receivingAddress = '' currentStep = 0 @@ -660,9 +612,7 @@ export default class MetadataForm extends mixins(validationMixin) { this.steps = steps this.stepNames = stepNames this.currentStep = currentStep - await this.loadFormData() - this.form = this.$store.state.metadata - this.loadReceivingAddress(this.form) + await this.populateForm() this.loading = false // check if project exists so we can display add/view @@ -693,35 +643,22 @@ export default class MetadataForm extends mixins(validationMixin) { this.txError = '' } + async populateForm(): Promise { + await this.loadFormData() + this.form = this.$store.state.metadata + } + updateFundReceivingAddress(): void { - if (!this.$v.receivingAddress.$invalid) { + if (!this.$v.form.fund?.$invalid) { const addresses = ReceivingAddress.fromArray( this.form.fund.receivingAddresses ) - addresses[chain.shortName] = this.receivingAddress + addresses[chain.shortName] = this.form.fund.currentChainReceivingAddress this.form.fund.receivingAddresses = ReceivingAddress.toArray(addresses) this.$v.form.fund?.receivingAddresses.$touch() } } - loadReceivingAddress(formData: MetadataFormData): void { - const fundingAddresses = formData.fund.receivingAddresses.reduce( - (addresses, chainAddressString) => { - const chainAddress = chainAddressString.split(':') - if (chainAddress.length == 2) { - addresses[chainAddress[0]] = chainAddress[1] - } - return addresses - }, - {} - ) - - if (fundingAddresses[chain.shortName]) { - this.receivingAddress = fundingAddresses[chain.shortName] - this.$v.receivingAddress.$touch() - } - } - initFurthestStep(): void { let step = 0 const validations = Object.keys(this.$v.form.$params) @@ -763,6 +700,10 @@ export default class MetadataForm extends mixins(validationMixin) { } isStepValid(step: number): boolean { + if (this.isWaiting) { + return false + } + const stepName: string = this.steps[step] if (stepName === 'links') { return this.isLinkStepValid() @@ -832,25 +773,29 @@ export default class MetadataForm extends mixins(validationMixin) { try { this.isWaiting = true const transaction = this.onSubmit(this.form, walletProvider) - const receipt = await waitForTransaction( - transaction, - (hash) => (this.txHash = hash) - ) + this.txHash = (await transaction).hash + const receipt = await waitForTransaction(transaction) + this.updateProgress(0, receipt.blockNumber) await Metadata.waitForBlock( receipt.blockNumber, chain.name, 0, this.updateProgress ) + this.isWaiting = false - if (this.onSuccess) { - this.onSuccess(receipt, this.$web3.chainId) - } + // reset so that add project will pick up the latest metadata + this.$store.commit(RESET_RECIPIENT_DATA) + + this.$router.push({ + name: 'metadata-success', + params: { hash: this.txHash, id: this.metadataId }, + }) } catch (error) { this.isWaiting = false - this.txError = error.message + this.txError = (error as Error).message return } } @@ -874,15 +819,10 @@ export default class MetadataForm extends mixins(validationMixin) { } } - onSuccess(txReceipt: ContractReceipt, chainId: number): void { - const { transactionHash: hash, from: owner } = txReceipt - let id = this.form.id - if (!id) { - const name = this.form.project.name || '' - id = Metadata.makeMetadataId(name, owner) - } - - this.receipt = { hash, id, chainId } + get metadataId(): string { + const projectName = this.form.project.name || '' + const owner = this.form.owner || '' + return this.form.id || Metadata.makeMetadataId(projectName, owner) } get metadataInterface(): Metadata { diff --git a/vue-app/src/views/MetadataTransactionSuccess.vue b/vue-app/src/views/MetadataTransactionSuccess.vue new file mode 100644 index 000000000..2398e2d93 --- /dev/null +++ b/vue-app/src/views/MetadataTransactionSuccess.vue @@ -0,0 +1,174 @@ +@ -0,0 +1,36 @@ + + + + + diff --git a/vue-app/src/views/MetadataViewer.vue b/vue-app/src/views/MetadataViewer.vue index 385d49000..2fa05fabf 100644 --- a/vue-app/src/views/MetadataViewer.vue +++ b/vue-app/src/views/MetadataViewer.vue @@ -31,7 +31,7 @@
-

About the metadata

+

About the project

Name

-
{{ metadata.name }}
+
{{ form.project.name }}
+
+ Project name is required. +

Deleted

@@ -53,7 +56,7 @@
Not provided
@@ -64,19 +67,36 @@

Tagline

-
{{ metadata.tagline }}
+
{{ form.project.tagline }}
+
+
+ Tagline is required. +
+
+ Project tagline must be less than 140 characters. +
+

Description

-
{{ metadata.description }}
+
{{ form.project.description }}
+
+ Description is required. +

Category

-
{{ metadata.category }}
+
{{ form.project.category }}
+
+ Category is required. +

Problem space

-
{{ metadata.problemSpace }}
+
{{ form.project.problemSpace }}
+
+ Problem space is required. +
@@ -90,19 +110,29 @@ />
-

Ethereum addresses

-
-
- +

Receiving address

+
+ +
+
+
+ Receiving address is required.
+
Invalid Ethereum 0x address

Funding plans

-
{{ metadata.plans }}
+
{{ form.fund.plans }}
+
+ Funding plans is required. +
@@ -117,15 +147,13 @@

Team name

-
{{ metadata.teamName }}
-
Not provided
+
{{ form.team.name }}
+
Not provided

Team description

-
{{ metadata.teamDescription }}
-
- Not provided -
+
{{ form.team.description }}
+
Not provided
@@ -138,70 +166,88 @@ >Edit
+
+ At least one link is required. +

GitHub

- {{ metadata.githubUrl }} + {{ form.links.github }}
-
Not provided
+
Not provided
+
+ This doesn't look like a valid URL. +

Twitter

- {{ metadata.twitterUrl }} + {{ form.links.twitter }}
-
Not provided
+
Not provided
+
+ This doesn't look like a valid URL. +

Website

- {{ metadata.websiteUrl }} + {{ form.links.website }}
-
Not provided
+
Not provided
+
+ This doesn't look like a valid URL. +

Discord

- {{ metadata.discordUrl }} + {{ form.links.discord }}
-
Not provided
+
Not provided
+
+ This doesn't look like a valid URL. +

Radicle

- {{ metadata.radicleUrl }} + {{ form.links.radicle }}
Not provided
+
+ This doesn't look like a valid URL. +
@@ -218,37 +264,55 @@

Banner

Not provided
+
+
+ Banner image is required. +
+
+ This doesn't look like a valid IPFS hash. +
+

Thumbnail

Not provided
+
+
+ Thumbnail image is required. +
+
+ This doesn't look like a valid IPFS hash. +
+
Delete metadata
@@ -271,14 +335,18 @@ import Links from '@/components/Links.vue' import MetadataSubmissionWidget from '@/components/MetadataSubmissionWidget.vue' import Box from '@/components/Box.vue' import TransactionResult from '@/components/TransactionResult.vue' -import { Metadata, MetadataFormData } from '@/api/metadata' +import { + Metadata, + MetadataFormData, + MetadataFormValidations, +} from '@/api/metadata' import { Project } from '@/api/projects' -import { chain } from '@/api/core' +import { chain, TransactionProgress } from '@/api/core' import { LinkInfo } from '@/api/types' import { isSameAddress } from '@/utils/accounts' +import { waitForTransaction } from '@/utils/contracts' import { CHAIN_INFO, CHAIN_ID } from '@/plugins/Web3/constants/chains' -import { ContractTransaction, ContractReceipt } from 'ethers' import { SET_METADATA } from '@/store/mutation-types' @Component({ @@ -293,19 +361,27 @@ import { SET_METADATA } from '@/store/mutation-types' TransactionResult, Box, }, + validations: { + form: MetadataFormValidations, + }, }) export default class MetadataViewer extends mixins(validationMixin) { @Prop() metadata!: Metadata @Prop() displayDeleteBtn!: boolean showSummaryPreview = false - deleteHash = '' deleteChainId = 0 + txHash = '' + isWaiting = false + progress: TransactionProgress | null = null + txError = '' + form: MetadataFormData = new Metadata({}).toFormData() created() { // make sure the edit form display the same data as the viewer - const updatedData = this.metadata?.toFormData() + this.form = this.metadata.toFormData() + this.$v.form.$touch() this.$store.commit(SET_METADATA, { - updatedData, + updatedData: this.form, }) } @@ -318,13 +394,17 @@ export default class MetadataViewer extends mixins(validationMixin) { } editLink(step: string): string { - const { id } = this.metadata || {} + const { id } = this.form || {} return `/metadata/${id}/edit/${step}` } + get noLinkProvided(): boolean { + return !Object.values(this.form?.links || []).some(Boolean) + } + get isAuthorized(): boolean { const { currentUser } = this.$store.state - const { owner, network } = this.metadata || {} + const { owner, network } = this.form || {} const walletAddress = currentUser?.walletAddress if (!currentUser || !owner || !walletAddress) { @@ -347,29 +427,37 @@ export default class MetadataViewer extends mixins(validationMixin) { this.showSummaryPreview = !this.showSummaryPreview } - get formData(): MetadataFormData { - const form = this.metadata.toFormData() - return form - } - - onDeleteSubmit( - form: MetadataFormData, - provider: any - ): Promise { - const { network } = form + async handleDelete(): Promise { + const { network, id } = this.form const { chainId } = this.$web3 + const { walletProvider } = this.$store.state.currentUser + + this.txError = '' if (CHAIN_INFO[chainId].name !== network) { throw new Error(`Deleting metadata on ${network} is not supported.`) } - const metadata = new Metadata({ id: form.id }) - return metadata.delete(provider) - } - - onDeleteSuccess(receipt: ContractReceipt, chainId: number): void { - this.deleteHash = receipt.transactionHash - this.deleteChainId = chainId + const metadata = new Metadata({ id }) + try { + this.isWaiting = true + const transaction = metadata.delete(walletProvider) + const receipt = await waitForTransaction( + transaction, + (hash) => (this.txHash = hash) + ) + + await Metadata.waitForBlock( + receipt.blockNumber, + chain.name, + 0, + (current, last) => (this.progress = { current, last }) + ) + this.isWaiting = false + } catch (error) { + this.txError = (error as Error).message + this.isWaiting = false + } } get metadataRegistryButton(): LinkInfo[] { @@ -381,26 +469,8 @@ export default class MetadataViewer extends mixins(validationMixin) { ] } - getChainId(network: string): number | undefined { - return CHAIN_ID[network] - } - - get receivingAddresses(): { address: string; chainId: number }[] { - const addresses = this.metadata.receivingAddresses || [] - - return addresses.map((addr) => { - const [shortName, address] = addr.split(':') - const chainId = CHAIN_ID[shortName] - - return { - address, - chainId, - } - }) - } - - get isEmailRequired(): boolean { - return !!process.env.VUE_APP_GOOGLE_SPREADSHEET_ID + get currentChainId(): number { + return CHAIN_ID[chain.name] } } @@ -550,21 +620,4 @@ export default class MetadataViewer extends mixins(validationMixin) { text-overflow: ellipsis; } } - -.no-break { - white-space: nowrap; -} - -.resolved-address { - overflow: hidden; - text-overflow: ellipsis; - opacity: 0.5; - word-break: keep-all; - font-size: 0.875rem; - margin-top: 0.25rem; -} - -.delete-title { - font-size: 1.5rem; -}