1
0
mirror of https://github.com/oceanprotocol/market.git synced 2024-12-02 05:57:29 +01:00

Update metadata, the proper way (#292)

* prototype view switching

* refactor, more UI

* formik form setup & data flow

* debug output, fixes, refactor

* description preview refactor

* publish/update date changes

* output created & updated date at top of asset
* use ddo.created & ddo.updated everywhere
* stop pushing metadata.main.datePublished

* owner check for edit link

* all the feedback states and switching between them: loading, error, success

* refactor feedback, one component for publish & edit

* action & date output fixes

* move all content, iterate form fields from it

* UI updates

* styling tweaks

* ddo dataflow refactor, more useAsset usage

* more useAsset usage

* form actions styling

* prepare edit history component

* metadata output tweaks

* copy

* safeguard against profile urls without protocol defined

* refetch ddo after edit

Signed-off-by: mihaisc <mihai.scarlat@smartcontrol.ro>

* switch author for dataTokenOptions in metadata preview

* refactor

* copy

* showPricing fix

* validation: minimum characters for title & description

* disable submit button when validation fails

* form validation fixes

* manually trigger onChange validation in publish & edit forms

Co-authored-by: mihaisc <mihai.scarlat@smartcontrol.ro>
This commit is contained in:
Matthias Kretschmann 2020-12-10 14:30:40 +01:00 committed by GitHub
parent c57731cd0b
commit 960c5b3234
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
46 changed files with 952 additions and 440 deletions

25
content/pages/edit.json Normal file
View File

@ -0,0 +1,25 @@
{
"description": "Update selected metadata of this data set. Updating metadata will create an on-chain transaction you have to approve in your wallet.",
"form": {
"success": "🎉 Successfully updated. 🎉",
"successAction": "Close",
"error": "Updating DDO failed.",
"data": [
{
"name": "name",
"label": "New Title",
"placeholder": "e.g. Shapes of Desert Plants",
"help": "Enter a concise title.",
"required": true
},
{
"name": "description",
"label": "New Description",
"help": "Add a thorough description with as much detail as possible. You can use [Markdown](https://daringfireball.net/projects/markdown/basics).",
"type": "textarea",
"rows": 10,
"required": true
}
]
}
}

View File

@ -0,0 +1,18 @@
import React, { ReactElement } from 'react'
export default function DebugOutput({
title,
output
}: {
title: string
output: any
}): ReactElement {
return (
<div>
<h5>{title}</h5>
<pre>
<code>{JSON.stringify(output, null, 2)}</code>
</pre>
</div>
)
}

View File

@ -44,15 +44,7 @@ export interface InputProps {
} }
export default function Input(props: Partial<InputProps>): ReactElement { export default function Input(props: Partial<InputProps>): ReactElement {
const { const { label, help, additionalComponent, size, field } = props
required,
name,
label,
help,
additionalComponent,
size,
field
} = props
const hasError = const hasError =
props.form?.touched[field.name] && props.form?.errors[field.name] props.form?.touched[field.name] && props.form?.errors[field.name]
@ -67,7 +59,7 @@ export default function Input(props: Partial<InputProps>): ReactElement {
className={styleClasses} className={styleClasses}
data-is-submitting={props.form?.isSubmitting ? true : null} data-is-submitting={props.form?.isSubmitting ? true : null}
> >
<Label htmlFor={name} required={required}> <Label htmlFor={props.name} required={props.required}>
{label} {label}
</Label> </Label>
<InputElement size={size} {...field} {...props} /> <InputElement size={size} {...field} {...props} />

View File

@ -5,6 +5,14 @@
word-break: break-word; word-break: break-word;
} }
.markdown h1,
.markdown h2,
.markdown h3,
.markdown h4,
.markdown h5 {
margin-bottom: calc(var(--spacer) / 2);
}
.markdown h1 { .markdown h1 {
font-size: var(--font-size-h3); font-size: var(--font-size-h3);
} }

View File

@ -17,7 +17,9 @@ export default function PublisherLinks({
? `https://twitter.com/${link.value}` ? `https://twitter.com/${link.value}`
: link.name === 'GitHub' : link.name === 'GitHub'
? `https://github.com/${link.value}` ? `https://github.com/${link.value}`
: link.value : link.value.includes('http') // safeguard against urls without protocol defined
? link.value
: `//${link.value}`
return ( return (
<a href={href} key={link.name} target="_blank" rel="noreferrer"> <a href={href} key={link.name} target="_blank" rel="noreferrer">

View File

@ -1,6 +1,5 @@
import React, { ReactElement, useEffect, useState } from 'react' import React, { ReactElement, useEffect, useState } from 'react'
import { format, formatDistance } from 'date-fns' import { format, formatDistance } from 'date-fns'
import { setDate } from 'date-fns/esm'
export default function Time({ export default function Time({
date, date,
@ -15,24 +14,26 @@ export default function Time({
}): ReactElement { }): ReactElement {
const [dateIso, setDateIso] = useState<string>() const [dateIso, setDateIso] = useState<string>()
const [dateNew, setDateNew] = useState<Date>() const [dateNew, setDateNew] = useState<Date>()
useEffect(() => { useEffect(() => {
if (!date) return if (!date) return
const dateNew = isUnix ? new Date(Number(date) * 1000) : new Date(date) const dateNew = isUnix ? new Date(Number(date) * 1000) : new Date(date)
setDateIso(dateNew.toISOString()) setDateIso(dateNew.toISOString())
setDateNew(dateNew) setDateNew(dateNew)
}, [date]) }, [date, isUnix])
return !dateIso || !dateNew ? ( return !dateIso || !dateNew ? (
<></> <></>
) : ( ) : (
<time <time
title={relative ? format(dateNew, 'MMMM d, yyyy') : undefined} title={format(dateNew, 'PPppp')}
dateTime={dateIso} dateTime={dateIso}
className={className || undefined} className={className || undefined}
> >
{relative {relative
? formatDistance(dateNew, Date.now(), { addSuffix: true }) ? formatDistance(dateNew, Date.now(), { addSuffix: true })
: format(dateNew, 'MMMM d, yyyy')} : format(dateNew, 'PP')}
</time> </time>
) )
} }

View File

@ -1,16 +1,25 @@
import React, { ReactElement } from 'react' import React, { ReactElement, useEffect } from 'react'
import { File as FileMetadata } from '@oceanprotocol/lib/dist/node/ddo/interfaces/File' import { File as FileMetadata } from '@oceanprotocol/lib/dist/node/ddo/interfaces/File'
import { prettySize } from '../../../../utils' import { prettySize } from '../../../../utils'
import cleanupContentType from '../../../../utils/cleanupContentType' import cleanupContentType from '../../../../utils/cleanupContentType'
import styles from './Info.module.css' import styles from './Info.module.css'
import { useField, useFormikContext } from 'formik'
export default function FileInfo({ export default function FileInfo({
file, name,
removeItem file
}: { }: {
name: string
file: FileMetadata file: FileMetadata
removeItem?(): void
}): ReactElement { }): ReactElement {
const { validateField } = useFormikContext()
const [field, meta, helpers] = useField(name)
// On mount, validate the field manually
useEffect(() => {
validateField(name)
}, [name, validateField])
return ( return (
<div className={styles.info}> <div className={styles.info}>
<h3 className={styles.url}>{file.url}</h3> <h3 className={styles.url}>{file.url}</h3>
@ -19,11 +28,12 @@ export default function FileInfo({
{file.contentLength && <li>{prettySize(+file.contentLength)}</li>} {file.contentLength && <li>{prettySize(+file.contentLength)}</li>}
{file.contentType && <li>{cleanupContentType(file.contentType)}</li>} {file.contentType && <li>{cleanupContentType(file.contentType)}</li>}
</ul> </ul>
{removeItem && ( <button
<button className={styles.removeButton} onClick={() => removeItem()}> className={styles.removeButton}
onClick={() => helpers.setValue(undefined)}
>
&times; &times;
</button> </button>
)}
</div> </div>
) )
} }

View File

@ -11,6 +11,10 @@ export default function FilesInput(props: InputProps): ReactElement {
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
async function handleButtonClick(e: React.SyntheticEvent, url: string) { async function handleButtonClick(e: React.SyntheticEvent, url: string) {
// hack so the onBlur-triggered validation does not show,
// like when this field is required
helpers.setTouched(false)
// File example 'https://oceanprotocol.com/tech-whitepaper.pdf' // File example 'https://oceanprotocol.com/tech-whitepaper.pdf'
e.preventDefault() e.preventDefault()
@ -26,14 +30,10 @@ export default function FilesInput(props: InputProps): ReactElement {
} }
} }
function removeItem() {
helpers.setValue(undefined)
}
return ( return (
<> <>
{field?.value && field.value[0] && typeof field.value === 'object' ? ( {field?.value && field.value[0] && typeof field.value === 'object' ? (
<FileInfo file={field.value[0]} removeItem={removeItem} /> <FileInfo name={props.name} file={field.value[0]} />
) : ( ) : (
<FileInput <FileInput
{...props} {...props}

View File

@ -9,7 +9,7 @@
} }
.box { .box {
composes: box from '../../atoms/Box.module.css'; composes: box from '../atoms/Box.module.css';
width: 100%; width: 100%;
} }

View File

@ -0,0 +1,78 @@
import Alert from '../atoms/Alert'
import Button from '../atoms/Button'
import Loader from '../atoms/Loader'
import React, { ReactElement } from 'react'
import styles from './MetadataFeedback.module.css'
import SuccessConfetti from '../atoms/SuccessConfetti'
interface Action {
name: string
onClick?: () => void
to?: string
}
function ActionSuccess({ action }: { action: Action }) {
const { name, onClick, to } = action
return (
<Button
style="primary"
size="small"
onClick={onClick || null}
to={to || null}
className={styles.action}
>
{name}
</Button>
)
}
function ActionError({ setError }: { setError: (error: string) => void }) {
return (
<Button
style="primary"
size="small"
className={styles.action}
onClick={() => setError(undefined)}
>
Try Again
</Button>
)
}
export default function MetadataFeedback({
title,
error,
success,
loading,
successAction,
setError
}: {
title: string
error: string
success: string
loading?: string
successAction: Action
setError: (error: string) => void
}): ReactElement {
return (
<div className={styles.feedback}>
<div className={styles.box}>
<h3>{title}</h3>
{error ? (
<>
<Alert text={error} state="error" />
<ActionError setError={setError} />
</>
) : success ? (
<SuccessConfetti
success={success}
action={<ActionSuccess action={successAction} />}
/>
) : (
<Loader message={loading} />
)}
</div>
</div>
)
}

View File

@ -24,11 +24,10 @@
margin: 0; margin: 0;
} }
.author { .datatoken {
margin-top: calc(var(--spacer) / 8); margin-top: calc(var(--spacer) / 8);
margin-bottom: 0; margin-bottom: 0;
color: var(--color-secondary); color: var(--color-secondary);
font-weight: var(--font-weight-bold);
} }
.preview [class*='MetaItem-module--metaItem'] h3 { .preview [class*='MetaItem-module--metaItem'] h3 {

View File

@ -0,0 +1,121 @@
import React, { FormEvent, ReactElement, useState } from 'react'
import { File as FileMetadata } from '@oceanprotocol/lib/dist/node/ddo/interfaces/File'
import Markdown from '../atoms/Markdown'
import Tags from '../atoms/Tags'
import MetaItem from '../organisms/AssetContent/MetaItem'
import styles from './MetadataPreview.module.css'
import File from '../atoms/File'
import { MetadataPublishForm } from '../../@types/MetaData'
import Button from '../atoms/Button'
import { transformTags } from '../../utils/metadata'
function Description({ description }: { description: string }) {
const [fullDescription, setFullDescription] = useState<boolean>(false)
const textLimit = 500 // string.length
const descriptionDisplay =
fullDescription === true
? description
: `${description.substring(0, textLimit)}${
description.length > textLimit ? `...` : ''
}`
function handleDescriptionToggle(e: FormEvent<HTMLButtonElement>) {
e.preventDefault()
setFullDescription(!fullDescription)
}
return (
<div className={styles.description}>
<Markdown text={descriptionDisplay} />
{description.length > textLimit && (
<Button
style="text"
size="small"
onClick={handleDescriptionToggle}
className={styles.toggle}
>
{fullDescription === true ? 'Close' : 'Expand'}
</Button>
)}
</div>
)
}
function MetaFull({ values }: { values: Partial<MetadataPublishForm> }) {
return (
<div className={styles.metaFull}>
{Object.entries(values)
.filter(
([key, value]) =>
!(
key.includes('name') ||
key.includes('description') ||
key.includes('tags') ||
key.includes('files') ||
key.includes('links') ||
key.includes('termsAndConditions') ||
key.includes('dataTokenOptions') ||
value === undefined ||
value === ''
)
)
.map(([key, value]) => (
<MetaItem key={key} title={key} content={value} />
))}
</div>
)
}
function Sample({ url }: { url: string }) {
return (
<Button
href={url}
target="_blank"
rel="noreferrer"
download
style="text"
size="small"
>
Download Sample
</Button>
)
}
export default function MetadataPreview({
values
}: {
values: Partial<MetadataPublishForm>
}): ReactElement {
return (
<div className={styles.preview}>
<h2 className={styles.previewTitle}>Preview</h2>
<header>
{values.name && <h3 className={styles.title}>{values.name}</h3>}
{values.dataTokenOptions?.name && (
<p
className={styles.datatoken}
>{`${values.dataTokenOptions.name}${values.dataTokenOptions.symbol}`}</p>
)}
{values.description && <Description description={values.description} />}
<div className={styles.asset}>
{values.files?.length > 0 && typeof values.files !== 'string' && (
<File
file={values.files[0] as FileMetadata}
className={styles.file}
small
/>
)}
</div>
{typeof values.links !== 'string' && values.links?.length && (
<Sample url={(values.links[0] as FileMetadata).url} />
)}
{values.tags && <Tags items={transformTags(values.tags)} />}
</header>
<MetaFull values={values} />
</div>
)
}

View File

@ -13,7 +13,6 @@ import {
usePricing usePricing
} from '@oceanprotocol/react' } from '@oceanprotocol/react'
import styles from './Compute.module.css' import styles from './Compute.module.css'
import Button from '../../atoms/Button'
import Input from '../../atoms/Input' import Input from '../../atoms/Input'
import Alert from '../../atoms/Alert' import Alert from '../../atoms/Alert'
import { useSiteMetadata } from '../../../hooks/useSiteMetadata' import { useSiteMetadata } from '../../../hooks/useSiteMetadata'

View File

@ -28,19 +28,17 @@ export default function Consume({
const [hasPreviousOrder, setHasPreviousOrder] = useState(false) const [hasPreviousOrder, setHasPreviousOrder] = useState(false)
const [previousOrderId, setPreviousOrderId] = useState<string>() const [previousOrderId, setPreviousOrderId] = useState<string>()
const { isInPurgatory, price } = useAsset() const { isInPurgatory, price } = useAsset()
const { const { buyDT, pricingStepText, pricingError, pricingIsLoading } = usePricing(
dtSymbol, ddo
buyDT, )
pricingStepText,
pricingError,
pricingIsLoading
} = usePricing(ddo)
const { consumeStepText, consume, consumeError } = useConsume() const { consumeStepText, consume, consumeError } = useConsume()
const [isDisabled, setIsDisabled] = useState(true) const [isDisabled, setIsDisabled] = useState(true)
const [hasDatatoken, setHasDatatoken] = useState(false) const [hasDatatoken, setHasDatatoken] = useState(false)
const [isConsumable, setIsConsumable] = useState(true) const [isConsumable, setIsConsumable] = useState(true)
useEffect(() => { useEffect(() => {
if (!price) return
setIsConsumable( setIsConsumable(
price.isConsumable !== undefined ? price.isConsumable === 'true' : true price.isConsumable !== undefined ? price.isConsumable === 'true' : true
) )
@ -110,14 +108,14 @@ export default function Consume({
</Button> </Button>
{hasDatatoken && ( {hasDatatoken && (
<div className={styles.help}> <div className={styles.help}>
You own {dtBalance} {dtSymbol} allowing you to use this data set You own {dtBalance} {ddo.dataTokenInfo.symbol} allowing you to use
without paying again. this data set without paying again.
</div> </div>
)} )}
{(!hasDatatoken || !hasPreviousOrder) && ( {(!hasDatatoken || !hasPreviousOrder) && (
<div className={styles.help}> <div className={styles.help}>
For using this data set, you will buy 1 {dtSymbol} and immediately For using this data set, you will buy 1 {ddo.dataTokenInfo.symbol}{' '}
spend it back to the publisher and pool. and immediately spend it back to the publisher and pool.
</div> </div>
)} )}
</> </>

View File

@ -0,0 +1,31 @@
import { DDO } from '@oceanprotocol/lib'
import React, { ReactElement } from 'react'
import { MetadataPublishForm } from '../../../../@types/MetaData'
import { transformPublishFormToMetadata } from '../../../../utils/metadata'
import DebugOutput from '../../../atoms/DebugOutput'
export default function Debug({
values,
ddo
}: {
values: Partial<MetadataPublishForm>
ddo: DDO
}): ReactElement {
const newDdo = {
'@context': 'https://w3id.org/did/v1',
service: [
{
index: 0,
type: 'metadata',
attributes: { ...transformPublishFormToMetadata(values, ddo) }
}
]
}
return (
<>
<DebugOutput title="Collected Form Values" output={values} />
<DebugOutput title="Transformed DDO Values" output={newDdo} />
</>
)
}

View File

@ -0,0 +1,24 @@
.form {
composes: box from '../../../atoms/Box.module.css';
}
.actions {
margin-left: -2rem;
margin-right: -2rem;
border-top: 1px solid var(--border-color);
padding: calc(var(--spacer) / 2) var(--spacer) 0;
display: flex;
justify-content: center;
}
@media (min-width: 40rem) {
.actions {
padding-top: var(--spacer);
}
}
.actions a,
.actions button {
margin-left: calc(var(--spacer) / 2);
margin-right: calc(var(--spacer) / 2);
}

View File

@ -0,0 +1,57 @@
import React, { ChangeEvent, ReactElement } from 'react'
import styles from './FormEditMetadata.module.css'
import { Field, Form, FormikContextType, useFormikContext } from 'formik'
import Button from '../../../atoms/Button'
import Input from '../../../atoms/Input'
import { useOcean } from '@oceanprotocol/react'
import { FormFieldProps } from '../../../../@types/Form'
import { MetadataPublishForm } from '../../../../@types/MetaData'
export default function FormEditMetadata({
data,
setShowEdit
}: {
data: FormFieldProps[]
setShowEdit: (show: boolean) => void
}): ReactElement {
const { ocean, accountId } = useOcean()
const {
isValid,
validateField,
setFieldValue
}: FormikContextType<Partial<MetadataPublishForm>> = useFormikContext()
// Manually handle change events instead of using `handleChange` from Formik.
// Workaround for default `validateOnChange` not kicking in
function handleFieldChange(
e: ChangeEvent<HTMLInputElement>,
field: FormFieldProps
) {
validateField(field.name)
setFieldValue(field.name, e.target.value)
}
return (
<Form className={styles.form}>
{data.map((field: FormFieldProps) => (
<Field
key={field.name}
{...field}
component={Input}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
handleFieldChange(e, field)
}
/>
))}
<footer className={styles.actions}>
<Button style="primary" disabled={!ocean || !accountId || !isValid}>
Submit
</Button>
<Button style="text" onClick={() => setShowEdit(false)}>
Cancel
</Button>
</footer>
</Form>
)
}

View File

@ -0,0 +1,10 @@
.grid {
composes: grid from '../../AssetContent/index.module.css';
margin-top: var(--spacer);
}
.description {
font-size: var(--font-size-large);
margin-top: -1.5rem;
max-width: 50rem;
}

View File

@ -0,0 +1,139 @@
import { useOcean } from '@oceanprotocol/react'
import { Formik } from 'formik'
import React, { ReactElement, useState } from 'react'
import { MetadataPublishForm } from '../../../../@types/MetaData'
import {
validationSchema,
getInitialValues
} from '../../../../models/FormEditMetadata'
import { useAsset } from '../../../../providers/Asset'
import { useUserPreferences } from '../../../../providers/UserPreferences'
import MetadataPreview from '../../../molecules/MetadataPreview'
import Debug from './Debug'
import Web3Feedback from '../../../molecules/Wallet/Feedback'
import FormEditMetadata from './FormEditMetadata'
import styles from './index.module.css'
import { Logger } from '@oceanprotocol/lib'
import MetadataFeedback from '../../../molecules/MetadataFeedback'
import { graphql, useStaticQuery } from 'gatsby'
const contentQuery = graphql`
query EditMetadataQuery {
content: allFile(filter: { relativePath: { eq: "pages/edit.json" } }) {
edges {
node {
childPagesJson {
description
form {
success
successAction
error
data {
name
placeholder
label
help
type
required
options
rows
}
}
}
}
}
}
}
`
export default function Edit({
setShowEdit
}: {
setShowEdit: (show: boolean) => void
}): ReactElement {
const data = useStaticQuery(contentQuery)
const content = data.content.edges[0].node.childPagesJson
const { debug } = useUserPreferences()
const { ocean, account } = useOcean()
const { did, metadata, ddo, refreshDdo } = useAsset()
const [success, setSuccess] = useState<string>()
const [error, setError] = useState<string>()
const hasFeedback = error || success
async function handleSubmit(
values: Partial<MetadataPublishForm>,
resetForm: () => void
) {
try {
const ddo = await ocean.assets.editMetadata(
did,
{ title: values.name, description: values.description },
account
)
// Edit failed
if (!ddo) {
setError(content.form.error)
Logger.error(content.form.error)
return
}
// Edit succeeded
setSuccess(content.form.success)
resetForm()
} catch (error) {
Logger.error(error.message)
setError(error.message)
}
}
return (
<Formik
initialValues={getInitialValues(metadata)}
validationSchema={validationSchema}
onSubmit={async (values, { resetForm }) => {
// move user's focus to top of screen
window.scrollTo({ top: 0, left: 0, behavior: 'smooth' })
// kick off editing
await handleSubmit(values, resetForm)
}}
>
{({ isSubmitting, values }) =>
isSubmitting || hasFeedback ? (
<MetadataFeedback
title="Updating Data Set"
error={error}
success={success}
setError={setError}
successAction={{
name: content.form.successAction,
onClick: async () => {
await refreshDdo()
setShowEdit(false)
}
}}
/>
) : (
<>
<p className={styles.description}>{content.description}</p>
<article className={styles.grid}>
<FormEditMetadata
data={content.form.data}
setShowEdit={setShowEdit}
/>
<aside>
<MetadataPreview values={values} />
<Web3Feedback />
</aside>
{debug === true && <Debug values={values} ddo={ddo} />}
</article>
</>
)
}
</Formik>
)
}

View File

@ -12,6 +12,7 @@ import Alert from '../../../../atoms/Alert'
import TokenBalance from '../../../../../@types/TokenBalance' import TokenBalance from '../../../../../@types/TokenBalance'
import { useUserPreferences } from '../../../../../providers/UserPreferences' import { useUserPreferences } from '../../../../../providers/UserPreferences'
import Output from './Output' import Output from './Output'
import DebugOutput from '../../../../atoms/DebugOutput'
const contentQuery = graphql` const contentQuery = graphql`
query PoolAddQuery { query PoolAddQuery {
@ -201,11 +202,7 @@ export default function Add({
action={submitForm} action={submitForm}
txId={txId} txId={txId}
/> />
{debug && ( {debug && <DebugOutput title="Collected values" output={values} />}
<pre>
<code>{JSON.stringify(values, null, 2)}</code>
</pre>
)}
</> </>
)} )}
</Formik> </Formik>

View File

@ -1,6 +1,6 @@
import React, { ReactElement, useEffect, useState } from 'react' import React, { ReactElement, useEffect, useState } from 'react'
import { useOcean, useMetadata, usePricing } from '@oceanprotocol/react' import { useOcean } from '@oceanprotocol/react'
import { DDO, Logger } from '@oceanprotocol/lib' import { Logger } from '@oceanprotocol/lib'
import styles from './index.module.css' import styles from './index.module.css'
import stylesActions from './Actions.module.css' import stylesActions from './Actions.module.css'
import PriceUnit from '../../../atoms/Price/PriceUnit' import PriceUnit from '../../../atoms/Price/PriceUnit'
@ -37,15 +37,20 @@ const contentQuery = graphql`
} }
` `
export default function Pool({ ddo }: { ddo: DDO }): ReactElement { export default function Pool(): ReactElement {
const data = useStaticQuery(contentQuery) const data = useStaticQuery(contentQuery)
const content = data.content.edges[0].node.childContentJson.pool const content = data.content.edges[0].node.childContentJson.pool
const { ocean, accountId, networkId, config } = useOcean() const { ocean, accountId, networkId, config } = useOcean()
const { owner } = useMetadata(ddo) const {
isInPurgatory,
const { dtSymbol } = usePricing(ddo) ddo,
const { isInPurgatory, price, refreshInterval, refreshPrice } = useAsset() owner,
price,
refreshInterval,
refreshPrice
} = useAsset()
const dtSymbol = ddo?.dataTokenInfo.symbol
const [poolTokens, setPoolTokens] = useState<string>() const [poolTokens, setPoolTokens] = useState<string>()
const [totalPoolTokens, setTotalPoolTokens] = useState<string>() const [totalPoolTokens, setTotalPoolTokens] = useState<string>()

View File

@ -1,14 +1,13 @@
import React, { ReactElement, useEffect, useState } from 'react' import React, { ReactElement, useEffect, useState } from 'react'
import { useOcean } from '@oceanprotocol/react' import { useOcean } from '@oceanprotocol/react'
import { DDO } from '@oceanprotocol/lib'
import FormTrade from './FormTrade' import FormTrade from './FormTrade'
import TokenBalance from '../../../../@types/TokenBalance' import TokenBalance from '../../../../@types/TokenBalance'
import { useAsset } from '../../../../providers/Asset' import { useAsset } from '../../../../providers/Asset'
export default function Trade({ ddo }: { ddo: DDO }): ReactElement { export default function Trade(): ReactElement {
const { ocean, balance, accountId } = useOcean() const { ocean, balance, accountId } = useOcean()
const [tokenBalance, setTokenBalance] = useState<TokenBalance>() const [tokenBalance, setTokenBalance] = useState<TokenBalance>()
const { price } = useAsset() const { price, ddo } = useAsset()
const [maxDt, setMaxDt] = useState(0) const [maxDt, setMaxDt] = useState(0)
const [maxOcean, setMaxOcean] = useState(0) const [maxOcean, setMaxOcean] = useState(0)

View File

@ -2,7 +2,7 @@ import React, { ReactElement, useState, useEffect } from 'react'
import styles from './index.module.css' import styles from './index.module.css'
import Compute from './Compute' import Compute from './Compute'
import Consume from './Consume' import Consume from './Consume'
import { DDO, Logger } from '@oceanprotocol/lib' import { Logger } from '@oceanprotocol/lib'
import Tabs from '../../atoms/Tabs' import Tabs from '../../atoms/Tabs'
import { useOcean } from '@oceanprotocol/react' import { useOcean } from '@oceanprotocol/react'
import compareAsBN from '../../../utils/compareAsBN' import compareAsBN from '../../../utils/compareAsBN'
@ -10,14 +10,13 @@ import Pool from './Pool'
import Trade from './Trade' import Trade from './Trade'
import { useAsset } from '../../../providers/Asset' import { useAsset } from '../../../providers/Asset'
export default function AssetActions({ ddo }: { ddo: DDO }): ReactElement { export default function AssetActions(): ReactElement {
const { ocean, balance, accountId } = useOcean() const { ocean, balance, accountId } = useOcean()
const { price } = useAsset() const { price, ddo, metadata } = useAsset()
const [isBalanceSufficient, setIsBalanceSufficient] = useState<boolean>() const [isBalanceSufficient, setIsBalanceSufficient] = useState<boolean>()
const [dtBalance, setDtBalance] = useState<string>() const [dtBalance, setDtBalance] = useState<string>()
const isCompute = Boolean(ddo.findServiceByType('compute')) const isCompute = Boolean(ddo?.findServiceByType('compute'))
const { attributes } = ddo.findServiceByType('metadata')
// Get and set user DT balance // Get and set user DT balance
useEffect(() => { useEffect(() => {
@ -60,7 +59,7 @@ export default function AssetActions({ ddo }: { ddo: DDO }): ReactElement {
ddo={ddo} ddo={ddo}
dtBalance={dtBalance} dtBalance={dtBalance}
isBalanceSufficient={isBalanceSufficient} isBalanceSufficient={isBalanceSufficient}
file={attributes.main.files[0]} file={metadata?.main.files[0]}
/> />
) )
@ -72,17 +71,17 @@ export default function AssetActions({ ddo }: { ddo: DDO }): ReactElement {
] ]
// Check from metadata, cause that is available earlier // Check from metadata, cause that is available earlier
const hasPool = ddo.price?.type === 'pool' const hasPool = ddo?.price?.type === 'pool'
hasPool && hasPool &&
tabs.push( tabs.push(
{ {
title: 'Pool', title: 'Pool',
content: <Pool ddo={ddo} /> content: <Pool />
}, },
{ {
title: 'Trade', title: 'Trade',
content: <Trade ddo={ddo} /> content: <Trade />
} }
) )

View File

@ -0,0 +1,41 @@
.title {
composes: title from './MetaItem.module.css';
margin-top: var(--spacer);
margin-bottom: calc(var(--spacer) / 2);
}
.history {
font-size: var(--font-size-small);
padding-left: 1.25rem;
}
.item {
position: relative;
display: block;
margin-bottom: calc(var(--spacer) / 4);
color: var(--color-secondary);
}
.item::before {
content: '▪';
position: absolute;
top: -1px;
left: -1.25rem;
color: var(--color-secondary);
user-select: none;
}
.item::after {
content: '';
position: absolute;
left: -1rem;
top: 62%;
display: block;
width: 1px;
height: 110%;
background-color: var(--color-secondary);
}
.item:last-child::after {
display: none;
}

View File

@ -0,0 +1,59 @@
import { useOcean } from '@oceanprotocol/react'
import React, { ReactElement, useEffect, useState } from 'react'
import { useAsset } from '../../../providers/Asset'
import EtherscanLink from '../../atoms/EtherscanLink'
import Time from '../../atoms/Time'
import styles from './EditHistory.module.css'
interface Receipt {
hash: string
timestamp: string
}
// TODO: fetch for real
const fakeReceipts = [
{
hash: '0xxxxxxxxx',
timestamp: '1607460269'
},
{
hash: '0xxxxxxxxx',
timestamp: '1606460159'
},
{
hash: '0xxxxxxxxx',
timestamp: '1506460159'
}
]
export default function EditHistory(): ReactElement {
const { networkId } = useOcean()
const { ddo } = useAsset()
const [receipts, setReceipts] = useState<Receipt[]>()
useEffect(() => {
setReceipts(fakeReceipts)
}, [])
return (
<>
<h3 className={styles.title}>Metadata History</h3>
<ul className={styles.history}>
{receipts?.map((receipt) => (
<li key={receipt.hash} className={styles.item}>
<EtherscanLink networkId={networkId} path={`/tx/${receipt.hash}`}>
edited <Time date={receipt.timestamp} relative isUnix />
</EtherscanLink>
</li>
))}
<li className={styles.item}>
{/* TODO: get this initial metadata creation tx somehow */}
<EtherscanLink networkId={networkId} path="/tx/xxx">
published <Time date={ddo.created} relative />
</EtherscanLink>
</li>
</ul>
</>
)
}

View File

@ -1,6 +1,5 @@
.metaFull { .metaFull {
margin-top: var(--spacer); margin-top: var(--spacer);
font-size: var(--font-size-small);
display: grid; display: grid;
gap: var(--spacer); gap: var(--spacer);
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
@ -14,3 +13,8 @@
word-break: break-all; word-break: break-all;
padding: 0; padding: 0;
} }
/* debug output */
.metaFull + div {
margin-top: var(--spacer);
}

View File

@ -2,21 +2,11 @@ import React, { ReactElement } from 'react'
import Time from '../../atoms/Time' import Time from '../../atoms/Time'
import MetaItem from './MetaItem' import MetaItem from './MetaItem'
import styles from './MetaFull.module.css' import styles from './MetaFull.module.css'
import { MetadataMarket } from '../../../@types/MetaData'
import { DDO } from '@oceanprotocol/lib'
import Publisher from '../../atoms/Publisher' import Publisher from '../../atoms/Publisher'
import { useAsset } from '../../../providers/Asset'
export default function MetaFull({ export default function MetaFull(): ReactElement {
ddo, const { ddo, metadata, isInPurgatory } = useAsset()
metadata,
isInPurgatory
}: {
ddo: DDO
metadata: MetadataMarket
isInPurgatory: boolean
}): ReactElement {
const { id, publicKey } = ddo
const { dateCreated, datePublished } = metadata.main
return ( return (
<div className={styles.metaFull}> <div className={styles.metaFull}>
@ -25,19 +15,19 @@ export default function MetaFull({
)} )}
<MetaItem <MetaItem
title="Owner" title="Owner"
content={<Publisher account={publicKey[0].owner} />} content={<Publisher account={ddo?.publicKey[0].owner} />}
/> />
{/* <MetaItem
title="Data Created"
content={<Time date={metadata?.main.dateCreated} />}
/> */}
{metadata?.additionalInformation?.categories && ( {/* TODO: remove those 2 date items here when EditHistory component is ready */}
<MetaItem <MetaItem title="Published" content={<Time date={ddo?.created} />} />
title="Category" {ddo?.created !== ddo?.updated && (
content={metadata?.additionalInformation?.categories[0]} <MetaItem title="Updated" content={<Time date={ddo?.updated} />} />
/>
)} )}
<MetaItem title="DID" content={<code>{ddo?.id}</code>} />
<MetaItem title="Data Created" content={<Time date={dateCreated} />} />
<MetaItem title="Published" content={<Time date={datePublished} />} />
<MetaItem title="DID" content={<code>{id}</code>} />
</div> </div>
) )
} }

View File

@ -3,6 +3,10 @@
} }
.title { .title {
font-family: var(--font-family-base);
font-weight: var(--font-weight-base);
font-size: var(--font-size-small); font-size: var(--font-size-small);
margin-bottom: calc(var(--spacer) / 4); margin-bottom: calc(var(--spacer) / 4);
color: var(--color-secondary);
text-transform: uppercase;
} }

View File

@ -0,0 +1,13 @@
.meta {
margin-bottom: calc(var(--spacer) / 1.5);
color: var(--color-secondary);
}
.meta p {
margin-bottom: 0;
}
.date {
font-size: var(--font-size-mini);
margin-top: calc(var(--spacer) / 2);
}

View File

@ -0,0 +1,34 @@
import { useOcean } from '@oceanprotocol/react'
import React, { ReactElement } from 'react'
import { useAsset } from '../../../providers/Asset'
import EtherscanLink from '../../atoms/EtherscanLink'
import Publisher from '../../atoms/Publisher'
import Time from '../../atoms/Time'
import styles from './MetaMain.module.css'
export default function MetaMain(): ReactElement {
const { ddo, owner } = useAsset()
const { networkId } = useOcean()
return (
<aside className={styles.meta}>
<p>
<EtherscanLink networkId={networkId} path={`token/${ddo?.dataToken}`}>
{`${ddo?.dataTokenInfo.name}${ddo?.dataTokenInfo.symbol}`}
</EtherscanLink>
</p>
<div>
Published By <Publisher account={owner} />
</div>
<p className={styles.date}>
<Time date={ddo?.created} relative />
{ddo?.created !== ddo?.updated && (
<>
{' — '}
updated <Time date={ddo?.updated} relative />
</>
)}
</p>
</aside>
)
}

View File

@ -14,7 +14,3 @@
.samples { .samples {
margin-top: var(--spacer); margin-top: var(--spacer);
} }
.date {
color: var(--color-secondary);
}

View File

@ -1,25 +1,13 @@
import React, { ReactElement } from 'react' import React, { ReactElement } from 'react'
import MetaItem from './MetaItem' import MetaItem from './MetaItem'
import styles from './MetaSecondary.module.css' import styles from './MetaSecondary.module.css'
import { MetadataMarket } from '../../../@types/MetaData'
import Tags from '../../atoms/Tags' import Tags from '../../atoms/Tags'
import Button from '../../atoms/Button' import Button from '../../atoms/Button'
import Time from '../../atoms/Time' import { useAsset } from '../../../providers/Asset'
export default function MetaSecondary({ const SampleButton = ({ url }: { url: string }) => (
metadata
}: {
metadata: MetadataMarket
}): ReactElement {
return (
<aside className={styles.metaSecondary}>
{metadata?.additionalInformation?.links?.length > 0 && (
<div className={styles.samples}>
<MetaItem
title="Sample Data"
content={
<Button <Button
href={metadata?.additionalInformation?.links[0].url} href={url}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
download download
@ -28,6 +16,21 @@ export default function MetaSecondary({
> >
Download Sample Download Sample
</Button> </Button>
)
export default function MetaSecondary(): ReactElement {
const { metadata } = useAsset()
return (
<aside className={styles.metaSecondary}>
{metadata?.additionalInformation?.links?.length > 0 && (
<div className={styles.samples}>
<MetaItem
title="Sample Data"
content={
<SampleButton
url={metadata?.additionalInformation?.links[0].url}
/>
} }
/> />
</div> </div>
@ -36,10 +39,6 @@ export default function MetaSecondary({
{metadata?.additionalInformation?.tags?.length > 0 && ( {metadata?.additionalInformation?.tags?.length > 0 && (
<Tags items={metadata?.additionalInformation?.tags} /> <Tags items={metadata?.additionalInformation?.tags} />
)} )}
<p className={styles.date}>
Published <Time date={metadata?.main.datePublished} relative />
</p>
</aside> </aside>
) )
} }

View File

@ -25,8 +25,6 @@ export default function Dynamic({
content: any content: any
}): ReactElement { }): ReactElement {
const { account, balance, networkId, refreshBalance } = useOcean() const { account, balance, networkId, refreshBalance } = useOcean()
const { dtSymbol, dtName } = usePricing(ddo)
const [firstPrice, setFirstPrice] = useState<string>() const [firstPrice, setFirstPrice] = useState<string>()
// Connect with form // Connect with form
@ -117,7 +115,10 @@ export default function Dynamic({
/> />
<Coin <Coin
name="dtAmount" name="dtAmount"
datatokenOptions={{ symbol: dtSymbol, name: dtName }} datatokenOptions={{
symbol: ddo.dataTokenInfo.symbol,
name: ddo.dataTokenInfo.name
}}
weight={`${Number(weightOnDataToken) * 10}%`} weight={`${Number(weightOnDataToken) * 10}%`}
readOnly readOnly
/> />

View File

@ -25,20 +25,20 @@
} }
} }
.meta { .ownerActions {
margin-bottom: var(--spacer); text-align: center;
color: var(--color-secondary);
}
.meta p {
margin-bottom: 0;
}
.datatoken a {
color: var(--color-secondary);
}
.buttonGroup {
margin-top: var(--spacer); margin-top: var(--spacer);
margin-bottom: var(--spacer); margin-bottom: calc(var(--spacer) * 1.5);
margin-left: -2rem;
margin-right: -2rem;
padding: calc(var(--spacer) / 4) var(--spacer);
border-top: 1px solid var(--border-color);
border-bottom: 1px solid var(--border-color);
}
.ownerActions a,
.ownerActions button {
color: var(--color-secondary);
margin-left: calc(var(--spacer) / 4);
margin-right: calc(var(--spacer) / 4);
} }

View File

@ -1,24 +1,23 @@
import { MetadataMarket } from '../../../@types/MetaData'
import React, { ReactElement, useEffect, useState } from 'react' import React, { ReactElement, useEffect, useState } from 'react'
import { graphql, Link, useStaticQuery } from 'gatsby' import { graphql, useStaticQuery } from 'gatsby'
import Markdown from '../../atoms/Markdown' import Markdown from '../../atoms/Markdown'
import MetaFull from './MetaFull' import MetaFull from './MetaFull'
import MetaSecondary from './MetaSecondary' import MetaSecondary from './MetaSecondary'
import styles from './index.module.css' import styles from './index.module.css'
import AssetActions from '../AssetActions' import AssetActions from '../AssetActions'
import { DDO } from '@oceanprotocol/lib'
import { useUserPreferences } from '../../../providers/UserPreferences' import { useUserPreferences } from '../../../providers/UserPreferences'
import Pricing from './Pricing' import Pricing from './Pricing'
import { useOcean, usePricing } from '@oceanprotocol/react' import { useOcean } from '@oceanprotocol/react'
import EtherscanLink from '../../atoms/EtherscanLink'
import Bookmark from './Bookmark' import Bookmark from './Bookmark'
import Publisher from '../../atoms/Publisher'
import { useAsset } from '../../../providers/Asset' import { useAsset } from '../../../providers/Asset'
import Alert from '../../atoms/Alert' import Alert from '../../atoms/Alert'
import Button from '../../atoms/Button'
import Edit from '../AssetActions/Edit'
import DebugOutput from '../../atoms/DebugOutput'
import MetaMain from './MetaMain'
// import EditHistory from './EditHistory'
export interface AssetContentProps { export interface AssetContentProps {
metadata: MetadataMarket
ddo: DDO
path?: string path?: string
} }
@ -39,54 +38,37 @@ const contentQuery = graphql`
} }
` `
export default function AssetContent({ export default function AssetContent(props: AssetContentProps): ReactElement {
metadata,
ddo
}: AssetContentProps): ReactElement {
const data = useStaticQuery(contentQuery) const data = useStaticQuery(contentQuery)
const content = data.purgatory.edges[0].node.childContentJson.asset const content = data.purgatory.edges[0].node.childContentJson.asset
const { debug } = useUserPreferences() const { debug } = useUserPreferences()
const { accountId, networkId } = useOcean() const { accountId } = useOcean()
const { owner, isInPurgatory, purgatoryData } = useAsset() const { owner, isInPurgatory, purgatoryData } = useAsset()
const { dtSymbol, dtName } = usePricing(ddo)
const [showPricing, setShowPricing] = useState(false) const [showPricing, setShowPricing] = useState(false)
const { price } = useAsset() const [showEdit, setShowEdit] = useState<boolean>()
const { ddo, price, metadata } = useAsset()
const isOwner = accountId === owner
useEffect(() => { useEffect(() => {
setShowPricing(accountId === owner && price.isConsumable === '') if (!price) return
}, [accountId, owner, price]) setShowPricing(isOwner && price.address === '')
}, [isOwner, price])
return ( function handleEditButton() {
// move user's focus to top of screen
window.scrollTo({ top: 0, left: 0, behavior: 'smooth' })
setShowEdit(true)
}
return showEdit ? (
<Edit setShowEdit={setShowEdit} />
) : (
<article className={styles.grid}> <article className={styles.grid}>
<div> <div>
{showPricing && <Pricing ddo={ddo} />} {showPricing && <Pricing ddo={ddo} />}
<div className={styles.content}> <div className={styles.content}>
{metadata?.additionalInformation?.categories?.length && ( <MetaMain />
<p> <Bookmark did={ddo.id} />
<Link
to={`/search?categories=${metadata?.additionalInformation?.categories[0]}`}
>
{metadata?.additionalInformation?.categories[0]}
</Link>
</p>
)}
<aside className={styles.meta}>
<p className={styles.datatoken}>
<EtherscanLink
networkId={networkId}
path={`token/${ddo.dataToken}`}
>
{dtName ? (
`${dtName}${dtSymbol}`
) : (
<code>{ddo.dataToken}</code>
)}
</EtherscanLink>
</p>
Published By <Publisher account={owner} />
</aside>
{isInPurgatory ? ( {isInPurgatory ? (
<Alert <Alert
@ -102,28 +84,26 @@ export default function AssetContent({
text={metadata?.additionalInformation?.description || ''} text={metadata?.additionalInformation?.description || ''}
/> />
<MetaSecondary metadata={metadata} /> <MetaSecondary />
{isOwner && (
<div className={styles.ownerActions}>
<Button style="text" size="small" onClick={handleEditButton}>
Edit Metadata
</Button>
</div>
)}
</> </>
)} )}
<MetaFull <MetaFull />
ddo={ddo} {/* <EditHistory /> */}
metadata={metadata} {debug === true && <DebugOutput title="DDO" output={ddo} />}
isInPurgatory={isInPurgatory}
/>
{debug === true && (
<pre>
<code>{JSON.stringify(ddo, null, 2)}</code>
</pre>
)}
<Bookmark did={ddo.id} />
</div> </div>
</div> </div>
<div className={styles.actions}> <div className={styles.actions}>
<AssetActions ddo={ddo} /> <AssetActions />
</div> </div>
</article> </article>
) )

View File

@ -1,16 +1,8 @@
import React, { ReactElement } from 'react' import React, { ReactElement } from 'react'
import { MetadataPublishForm } from '../../../@types/MetaData' import { MetadataPublishForm } from '../../../@types/MetaData'
import DebugOutput from '../../atoms/DebugOutput'
import styles from './index.module.css' import styles from './index.module.css'
import { transformPublishFormToMetadata } from './utils' import { transformPublishFormToMetadata } from '../../../utils/metadata'
const Output = ({ title, output }: { title: string; output: any }) => (
<div>
<h5>{title}</h5>
<pre>
<code>{JSON.stringify(output, null, 2)}</code>
</pre>
</div>
)
export default function Debug({ export default function Debug({
values values
@ -38,8 +30,8 @@ export default function Debug({
return ( return (
<div className={styles.grid}> <div className={styles.grid}>
<Output title="Collected Form Values" output={values} /> <DebugOutput title="Collected Form Values" output={values} />
<Output title="Transformed DDO Values" output={ddo} /> <DebugOutput title="Transformed DDO Values" output={ddo} />
</div> </div>
) )
} }

View File

@ -1,57 +0,0 @@
import Alert from '../../atoms/Alert'
import Button from '../../atoms/Button'
import Loader from '../../atoms/Loader'
import React, { ReactElement } from 'react'
import styles from './Feedback.module.css'
import SuccessConfetti from '../../atoms/SuccessConfetti'
import { DDO } from '@oceanprotocol/lib'
export default function Feedback({
error,
success,
ddo,
publishStepText,
setError
}: {
error: string
success: string
ddo: DDO
publishStepText: string
setError: (error: string) => void
}): ReactElement {
const SuccessAction = () => (
<Button
style="primary"
size="small"
to={`/asset/${ddo?.id}`}
className={styles.action}
>
Go to data set
</Button>
)
return (
<div className={styles.feedback}>
<div className={styles.box}>
<h3>Publishing Data Set</h3>
{error ? (
<>
<Alert text={error} state="error" />
<Button
style="primary"
size="small"
className={styles.action}
onClick={() => setError(undefined)}
>
Try Again
</Button>
</>
) : success ? (
<SuccessConfetti success={success} action={<SuccessAction />} />
) : (
<Loader message={publishStepText} />
)}
</div>
</div>
)
}

View File

@ -1,10 +1,11 @@
import React, { ReactElement, useEffect, FormEvent } from 'react' import React, { ReactElement, useEffect, FormEvent, ChangeEvent } from 'react'
import styles from './FormPublish.module.css' import styles from './FormPublish.module.css'
import { useOcean } from '@oceanprotocol/react' import { useOcean } from '@oceanprotocol/react'
import { useFormikContext, Field, Form } from 'formik' import { useFormikContext, Field, Form, FormikContextType } from 'formik'
import Input from '../../atoms/Input' import Input from '../../atoms/Input'
import Button from '../../atoms/Button' import Button from '../../atoms/Button'
import { FormContent, FormFieldProps } from '../../../@types/Form' import { FormContent, FormFieldProps } from '../../../@types/Form'
import { MetadataPublishForm } from '../../../@types/MetaData'
export default function FormPublish({ export default function FormPublish({
content content
@ -19,8 +20,10 @@ export default function FormPublish({
setErrors, setErrors,
setTouched, setTouched,
resetForm, resetForm,
initialValues initialValues,
} = useFormikContext() validateField,
setFieldValue
}: FormikContextType<MetadataPublishForm> = useFormikContext()
// reset form validation on every mount // reset form validation on every mount
useEffect(() => { useEffect(() => {
@ -30,6 +33,16 @@ export default function FormPublish({
// setSubmitting(false) // setSubmitting(false)
}, [setErrors, setTouched]) }, [setErrors, setTouched])
// Manually handle change events instead of using `handleChange` from Formik.
// Workaround for default `validateOnChange` not kicking in
function handleFieldChange(
e: ChangeEvent<HTMLInputElement>,
field: FormFieldProps
) {
validateField(field.name)
setFieldValue(field.name, e.target.value)
}
const resetFormAndClearStorage = (e: FormEvent<Element>) => { const resetFormAndClearStorage = (e: FormEvent<Element>) => {
e.preventDefault() e.preventDefault()
resetForm({ values: initialValues, status: 'empty' }) resetForm({ values: initialValues, status: 'empty' })
@ -43,7 +56,14 @@ export default function FormPublish({
onChange={() => status === 'empty' && setStatus(null)} onChange={() => status === 'empty' && setStatus(null)}
> >
{content.data.map((field: FormFieldProps) => ( {content.data.map((field: FormFieldProps) => (
<Field key={field.name} {...field} component={Input} /> <Field
key={field.name}
{...field}
component={Input}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
handleFieldChange(e, field)
}
/>
))} ))}
<footer className={styles.actions}> <footer className={styles.actions}>

View File

@ -1,103 +0,0 @@
import React, { FormEvent, ReactElement, useState } from 'react'
import { File as FileMetadata } from '@oceanprotocol/lib/dist/node/ddo/interfaces/File'
import Markdown from '../../atoms/Markdown'
import Tags from '../../atoms/Tags'
import MetaItem from '../../organisms/AssetContent/MetaItem'
import styles from './Preview.module.css'
import File from '../../atoms/File'
import { MetadataPublishForm } from '../../../@types/MetaData'
import Button from '../../atoms/Button'
import { transformTags } from './utils'
export default function Preview({
values
}: {
values: Partial<MetadataPublishForm>
}): ReactElement {
const [fullDescription, setFullDescription] = useState<boolean>(false)
const textLimit = 500 // string.length
const description =
fullDescription === true
? values.description
: `${values.description.substring(0, textLimit)}${
values.description.length > textLimit ? `...` : ''
}`
function handleDescriptionToggle(e: FormEvent<HTMLButtonElement>) {
e.preventDefault()
setFullDescription(!fullDescription)
}
return (
<div className={styles.preview}>
<h2 className={styles.previewTitle}>Preview</h2>
<header>
{values.name && <h3 className={styles.title}>{values.name}</h3>}
{values.author && <p className={styles.author}>{values.author}</p>}
{values.description && (
<div className={styles.description}>
<Markdown text={description} />
{values.description.length > textLimit && (
<Button
style="text"
size="small"
onClick={handleDescriptionToggle}
className={styles.toggle}
>
{fullDescription === true ? 'Close' : 'Expand'}
</Button>
)}
</div>
)}
<div className={styles.asset}>
{values.files?.length > 0 && typeof values.files !== 'string' && (
<File
file={values.files[0] as FileMetadata}
className={styles.file}
small
/>
)}
</div>
{typeof values.links !== 'string' && values.links?.length && (
<Button
href={(values.links[0] as FileMetadata).url}
target="_blank"
rel="noreferrer"
download
style="text"
size="small"
>
Download Sample
</Button>
)}
{values.tags && <Tags items={transformTags(values.tags)} />}
</header>
<div className={styles.metaFull}>
{Object.entries(values)
.filter(
([key, value]) =>
!(
key.includes('author') ||
key.includes('name') ||
key.includes('description') ||
key.includes('tags') ||
key.includes('files') ||
key.includes('links') ||
key.includes('termsAndConditions') ||
key.includes('dataTokenOptions') ||
value === undefined ||
value === ''
)
)
.map(([key, value]) => (
<MetaItem key={key} title={key} content={value} />
))}
</div>
</div>
)
}

View File

@ -6,15 +6,15 @@ import FormPublish from './FormPublish'
import Web3Feedback from '../../molecules/Wallet/Feedback' import Web3Feedback from '../../molecules/Wallet/Feedback'
import { FormContent } from '../../../@types/Form' import { FormContent } from '../../../@types/Form'
import { initialValues, validationSchema } from '../../../models/FormPublish' import { initialValues, validationSchema } from '../../../models/FormPublish'
import { transformPublishFormToMetadata } from './utils' import { transformPublishFormToMetadata } from '../../../utils/metadata'
import Preview from './Preview' import MetadataPreview from '../../molecules/MetadataPreview'
import { MetadataPublishForm } from '../../../@types/MetaData' import { MetadataPublishForm } from '../../../@types/MetaData'
import { useUserPreferences } from '../../../providers/UserPreferences' import { useUserPreferences } from '../../../providers/UserPreferences'
import { DDO, Logger, Metadata } from '@oceanprotocol/lib' import { Logger, Metadata } from '@oceanprotocol/lib'
import { Persist } from '../../atoms/FormikPersist' import { Persist } from '../../atoms/FormikPersist'
import Debug from './Debug' import Debug from './Debug'
import Feedback from './Feedback'
import Alert from '../../atoms/Alert' import Alert from '../../atoms/Alert'
import MetadataFeedback from '../../molecules/MetadataFeedback'
const formName = 'ocean-publish-form' const formName = 'ocean-publish-form'
@ -28,7 +28,7 @@ export default function PublishPage({
const { isInPurgatory, purgatoryData } = useOcean() const { isInPurgatory, purgatoryData } = useOcean()
const [success, setSuccess] = useState<string>() const [success, setSuccess] = useState<string>()
const [error, setError] = useState<string>() const [error, setError] = useState<string>()
const [ddo, setDdo] = useState<DDO>() const [did, setDid] = useState<string>()
const hasFeedback = isLoading || error || success const hasFeedback = isLoading || error || success
@ -61,7 +61,7 @@ export default function PublishPage({
} }
// Publish succeeded // Publish succeeded
setDdo(ddo) setDid(ddo.id)
setSuccess( setSuccess(
'🎉 Successfully published. 🎉 Now create a price on your data set.' '🎉 Successfully published. 🎉 Now create a price on your data set.'
) )
@ -77,12 +77,11 @@ export default function PublishPage({
initialValues={initialValues} initialValues={initialValues}
initialStatus="empty" initialStatus="empty"
validationSchema={validationSchema} validationSchema={validationSchema}
onSubmit={async (values, { setSubmitting, resetForm }) => { onSubmit={async (values, { resetForm }) => {
// move user's focus to top of screen // move user's focus to top of screen
window.scrollTo({ top: 0, left: 0, behavior: 'smooth' }) window.scrollTo({ top: 0, left: 0, behavior: 'smooth' })
// kick off publishing // kick off publishing
await handleSubmit(values, resetForm) await handleSubmit(values, resetForm)
setSubmitting(false)
}} }}
> >
{({ values }) => ( {({ values }) => (
@ -90,12 +89,16 @@ export default function PublishPage({
<Persist name={formName} ignoreFields={['isSubmitting']} /> <Persist name={formName} ignoreFields={['isSubmitting']} />
{hasFeedback ? ( {hasFeedback ? (
<Feedback <MetadataFeedback
title="Publishing Data Set"
error={error} error={error}
success={success} success={success}
publishStepText={publishStepText} loading={publishStepText}
ddo={ddo}
setError={setError} setError={setError}
successAction={{
name: 'Go to data set →',
to: `/asset/${did}`
}}
/> />
) : ( ) : (
<> <>
@ -109,7 +112,7 @@ export default function PublishPage({
<aside> <aside>
<div className={styles.sticky}> <div className={styles.sticky}>
<Preview values={values} /> <MetadataPreview values={values} />
<Web3Feedback /> <Web3Feedback />
</div> </div>
</aside> </aside>

View File

@ -2,7 +2,6 @@ import React, { useState, useEffect, ReactElement } from 'react'
import { Router } from '@reach/router' import { Router } from '@reach/router'
import AssetContent from '../organisms/AssetContent' import AssetContent from '../organisms/AssetContent'
import Page from './Page' import Page from './Page'
import { MetadataMarket } from '../../@types/MetaData'
import Alert from '../atoms/Alert' import Alert from '../atoms/Alert'
import Loader from '../atoms/Loader' import Loader from '../atoms/Loader'
import { useAsset } from '../../providers/Asset' import { useAsset } from '../../providers/Asset'
@ -12,37 +11,29 @@ export default function PageTemplateAssetDetails({
}: { }: {
uri: string uri: string
}): ReactElement { }): ReactElement {
const { isInPurgatory } = useAsset() const { ddo, title, error, isInPurgatory } = useAsset()
const [metadata, setMetadata] = useState<MetadataMarket>() const [pageTitle, setPageTitle] = useState<string>()
const [title, setTitle] = useState<string>()
const { ddo, error } = useAsset()
useEffect(() => { useEffect(() => {
if (!ddo || error) { if (!ddo || error) {
setTitle('Could not retrieve asset') setPageTitle('Could not retrieve asset')
return return
} }
const { attributes } = ddo.findServiceByType('metadata') setPageTitle(isInPurgatory ? '' : title)
setTitle(isInPurgatory ? '' : attributes.main.name) }, [ddo, error, isInPurgatory, title])
setMetadata((attributes as unknown) as MetadataMarket)
}, [ddo, error, isInPurgatory])
return ddo && metadata ? ( return ddo ? (
<> <>
<Page title={title} uri={uri}> <Page title={pageTitle} uri={uri}>
<Router basepath="/asset"> <Router basepath="/asset">
<AssetContent <AssetContent path=":did" />
ddo={ddo}
metadata={metadata as MetadataMarket}
path=":did"
/>
</Router> </Router>
</Page> </Page>
</> </>
) : error ? ( ) : error ? (
<Page title={title} noPageHeader uri={uri}> <Page title={pageTitle} noPageHeader uri={uri}>
<Alert title={title} text={error} state="error" /> <Alert title={pageTitle} text={error} state="error" />
</Page> </Page>
) : ( ) : (
<Page title={undefined} uri={uri}> <Page title={undefined} uri={uri}>

View File

@ -0,0 +1,20 @@
import { MetadataMarket, MetadataPublishForm } from '../@types/MetaData'
import * as Yup from 'yup'
export const validationSchema = Yup.object().shape<
Partial<MetadataPublishForm>
>({
name: Yup.string()
.min(4, (param) => `Title must be at least ${param.min} characters`)
.required('Required'),
description: Yup.string().required('Required').min(10)
})
export function getInitialValues(
metadata: MetadataMarket
): Partial<MetadataPublishForm> {
return {
name: metadata.main.name,
description: metadata.additionalInformation.description
}
}

View File

@ -4,7 +4,9 @@ import * as Yup from 'yup'
export const validationSchema = Yup.object().shape<MetadataPublishForm>({ export const validationSchema = Yup.object().shape<MetadataPublishForm>({
// ---- required fields ---- // ---- required fields ----
name: Yup.string().required('Required'), name: Yup.string()
.min(4, (param) => `Title must be at least ${param.min} characters`)
.required('Required'),
author: Yup.string().required('Required'), author: Yup.string().required('Required'),
dataTokenOptions: Yup.object() dataTokenOptions: Yup.object()
.shape({ .shape({
@ -13,7 +15,7 @@ export const validationSchema = Yup.object().shape<MetadataPublishForm>({
}) })
.required('Required'), .required('Required'),
files: Yup.array<FileMetadata>().required('Required').nullable(), files: Yup.array<FileMetadata>().required('Required').nullable(),
description: Yup.string().required('Required'), description: Yup.string().min(10).required('Required'),
access: Yup.string() access: Yup.string()
.matches(/Compute|Download/g) .matches(/Compute|Download/g)
.required('Required'), .required('Required'),

View File

@ -7,25 +7,27 @@ import React, {
useCallback, useCallback,
ReactNode ReactNode
} from 'react' } from 'react'
import { Logger, DDO, Metadata, BestPrice } from '@oceanprotocol/lib' import { Logger, DDO, BestPrice } from '@oceanprotocol/lib'
import { PurgatoryData } from '@oceanprotocol/lib/dist/node/ddo/interfaces/PurgatoryData' import { PurgatoryData } from '@oceanprotocol/lib/dist/node/ddo/interfaces/PurgatoryData'
import { getDataTokenPrice, useOcean } from '@oceanprotocol/react' import { getDataTokenPrice, useOcean } from '@oceanprotocol/react'
import getAssetPurgatoryData from '../utils/purgatory' import getAssetPurgatoryData from '../utils/purgatory'
import { ConfigHelperConfig } from '@oceanprotocol/lib/dist/node/utils/ConfigHelper' import { ConfigHelperConfig } from '@oceanprotocol/lib/dist/node/utils/ConfigHelper'
import axios from 'axios' import axios, { CancelToken } from 'axios'
import { retrieveDDO } from '../utils/aquarius' import { retrieveDDO } from '../utils/aquarius'
import { MetadataMarket } from '../@types/MetaData'
interface AssetProviderValue { interface AssetProviderValue {
isInPurgatory: boolean isInPurgatory: boolean
purgatoryData: PurgatoryData purgatoryData: PurgatoryData
ddo: DDO | undefined ddo: DDO | undefined
did: string | undefined did: string | undefined
metadata: Metadata | undefined metadata: MetadataMarket | undefined
title: string | undefined title: string | undefined
owner: string | undefined owner: string | undefined
price: BestPrice | undefined price: BestPrice | undefined
error?: string error?: string
refreshInterval: number refreshInterval: number
refreshDdo: (token?: CancelToken) => Promise<void>
refreshPrice: () => Promise<void> refreshPrice: () => Promise<void>
} }
@ -45,7 +47,7 @@ function AssetProvider({
const [purgatoryData, setPurgatoryData] = useState<PurgatoryData>() const [purgatoryData, setPurgatoryData] = useState<PurgatoryData>()
const [ddo, setDDO] = useState<DDO>() const [ddo, setDDO] = useState<DDO>()
const [did, setDID] = useState<string>() const [did, setDID] = useState<string>()
const [metadata, setMetadata] = useState<Metadata>() const [metadata, setMetadata] = useState<MetadataMarket>()
const [title, setTitle] = useState<string>() const [title, setTitle] = useState<string>()
const [price, setPrice] = useState<BestPrice>() const [price, setPrice] = useState<BestPrice>()
const [owner, setOwner] = useState<string>() const [owner, setOwner] = useState<string>()
@ -69,6 +71,29 @@ function AssetProvider({
Logger.log(`Refreshed asset price: ${newPrice?.value}`) Logger.log(`Refreshed asset price: ${newPrice?.value}`)
}, [ocean, config, ddo, networkId, status]) }, [ocean, config, ddo, networkId, status])
const fetchDdo = async (token?: CancelToken) => {
Logger.log('Init asset, get ddo')
const ddo = await retrieveDDO(
asset as string,
config.metadataCacheUri,
token
)
if (!ddo) {
setError(
`The DDO for ${asset} was not found in MetadataCache. If you just published a new data set, wait some seconds and refresh this page.`
)
} else {
setError(undefined)
}
return ddo
}
const refreshDdo = async (token?: CancelToken) => {
const ddo = await fetchDdo(token)
Logger.debug('DDO', ddo)
setDDO(ddo)
}
// //
// Get and set DDO based on passed DDO or DID // Get and set DDO based on passed DDO or DID
// //
@ -79,28 +104,14 @@ function AssetProvider({
let isMounted = true let isMounted = true
Logger.log('Init asset, get ddo') Logger.log('Init asset, get ddo')
async function init(): Promise<void> { async function init() {
const ddo = await retrieveDDO( const ddo = await fetchDdo(source.token)
asset as string,
config.metadataCacheUri,
source.token
)
if (!ddo) {
setError(
`The DDO for ${asset} was not found in MetadataCache. If you just published a new data set, wait some seconds and refresh this page.`
)
} else {
setError(undefined)
}
if (!isMounted) return if (!isMounted) return
Logger.debug('DDO', ddo) Logger.debug('DDO', ddo)
setDDO(ddo) setDDO(ddo)
setDID(asset as string) setDID(asset as string)
} }
init() init()
return () => { return () => {
isMounted = false isMounted = false
source.cancel() source.cancel()
@ -130,10 +141,10 @@ function AssetProvider({
if (result?.did !== undefined) { if (result?.did !== undefined) {
setIsInPurgatory(true) setIsInPurgatory(true)
setPurgatoryData(result) setPurgatoryData(result)
} else { return
setIsInPurgatory(false)
} }
setPurgatoryData(result)
setIsInPurgatory(false)
} catch (error) { } catch (error) {
Logger.error(error) Logger.error(error)
} }
@ -147,7 +158,7 @@ function AssetProvider({
// Set price & metadata from DDO first // Set price & metadata from DDO first
setPrice(ddo.price) setPrice(ddo.price)
const { attributes } = ddo.findServiceByType('metadata') const { attributes } = ddo.findServiceByType('metadata')
setMetadata(attributes) setMetadata((attributes as unknown) as MetadataMarket)
setTitle(attributes?.main.name) setTitle(attributes?.main.name)
setOwner(ddo.publicKey[0].owner) setOwner(ddo.publicKey[0].owner)
setIsInPurgatory(ddo.isInPurgatory === 'true') setIsInPurgatory(ddo.isInPurgatory === 'true')
@ -177,6 +188,7 @@ function AssetProvider({
isInPurgatory, isInPurgatory,
purgatoryData, purgatoryData,
refreshInterval, refreshInterval,
refreshDdo,
refreshPrice refreshPrice
} as AssetProviderValue } as AssetProviderValue
} }

View File

@ -1,7 +1,8 @@
import { MetadataMarket, MetadataPublishForm } from '../../../@types/MetaData' import { MetadataMarket, MetadataPublishForm } from '../@types/MetaData'
import { toStringNoMS } from '../../../utils' import { toStringNoMS } from '.'
import AssetModel from '../../../models/Asset' import AssetModel from '../models/Asset'
import slugify from '@sindresorhus/slugify' import slugify from '@sindresorhus/slugify'
import { DDO } from '@oceanprotocol/lib'
export function transformTags(value: string): string[] { export function transformTags(value: string): string[] {
const originalTags = value?.split(',') const originalTags = value?.split(',')
@ -10,11 +11,7 @@ export function transformTags(value: string): string[] {
} }
export function transformPublishFormToMetadata( export function transformPublishFormToMetadata(
data: Partial<MetadataPublishForm> {
): MetadataMarket {
const currentTime = toStringNoMS(new Date())
const {
name, name,
author, author,
description, description,
@ -22,15 +19,17 @@ export function transformPublishFormToMetadata(
links, links,
termsAndConditions, termsAndConditions,
files files
} = data }: Partial<MetadataPublishForm>,
ddo?: DDO
): MetadataMarket {
const currentTime = toStringNoMS(new Date())
const metadata: MetadataMarket = { const metadata: MetadataMarket = {
main: { main: {
...AssetModel.main, ...AssetModel.main,
name, name,
author, author,
dateCreated: currentTime, dateCreated: ddo ? ddo.created : currentTime,
datePublished: currentTime,
files: typeof files !== 'string' && files, files: typeof files !== 'string' && files,
license: 'https://market.oceanprotocol.com/terms' license: 'https://market.oceanprotocol.com/terms'
}, },

View File

@ -1,6 +1,6 @@
import React from 'react' import React from 'react'
import { render } from '@testing-library/react' import { render } from '@testing-library/react'
import { transformPublishFormToMetadata } from '../../../src/components/pages/Publish/utils' import { transformPublishFormToMetadata } from '../../../src/utils/metadata'
import { import {
MetadataMarket, MetadataMarket,
MetadataPublishForm MetadataPublishForm