diff --git a/content/pages/publish.json b/content/pages/publish.json deleted file mode 100644 index 8da56c3a0..000000000 --- a/content/pages/publish.json +++ /dev/null @@ -1,84 +0,0 @@ -{ - "title": "Publish", - "description": "Highlight the important features of your data set to make it more discoverable and catch the interest of data consumers.", - "warning": "Given the beta status, publishing on Ropsten or Rinkeby first is strongly recommended. Please familiarize yourself with [the market](https://oceanprotocol.com/technology/marketplaces), [the risks](https://blog.oceanprotocol.com/on-staking-on-data-in-ocean-market-3d8e09eb0a13), and the [Terms of Use](/terms).", - "form": { - "title": "Publish", - "data": [ - { - "name": "name", - "label": "Title", - "placeholder": "e.g. Shapes of Desert Plants", - "help": "Enter a concise title.", - "required": true - }, - { - "name": "description", - "label": "Description", - "help": "Add a thorough description with as much detail as possible. You can use [Markdown](https://daringfireball.net/projects/markdown/basics).", - "type": "textarea", - "required": true - }, - { - "name": "files", - "label": "File", - "placeholder": "e.g. https://file.com/file.json", - "help": "Please provide a URL to your data set file. This URL will be stored encrypted after publishing.", - "type": "files", - "required": true - }, - { - "name": "links", - "label": "Sample file", - "placeholder": "e.g. https://file.com/samplefile.json", - "help": "Please provide a URL to a sample of your data set file. This file should reveal the data structure of your data set, e.g. by including the header and one line of a CSV file. This file URL will be publicly available after publishing.", - "type": "files" - }, - { - "name": "access", - "label": "Access Type", - "help": "Choose how you want your files to be accessible for the specified price.", - "type": "select", - "options": ["Download"], - "required": true - }, - { - "name": "timeout", - "label": "Timeout", - "help": "Define how long buyers should be able to download the data set again after the initial purchase.", - "type": "select", - "options": ["Forever", "1 day", "1 week", "1 month", "1 year"], - "sortOptions": false, - "required": true - }, - { - "name": "dataTokenOptions", - "label": "Datatoken Name & Symbol", - "type": "datatoken", - "help": "The datatoken for this data set will be created with this name & symbol.", - "required": true - }, - { - "name": "author", - "label": "Author", - "placeholder": "e.g. Jelly McJellyfish", - "help": "Give proper attribution for your data set.", - "required": true - }, - { - "name": "tags", - "label": "Tags", - "placeholder": "e.g. logistics, ai", - "help": "Separate tags with comma." - }, - { - "name": "termsAndConditions", - "label": "Terms & Conditions", - "type": "terms", - "options": ["I agree to these Terms and Conditions"], - "required": true - } - ], - "success": "Asset Created!" - } -} diff --git a/content/pages/publish/form-algorithm.json b/content/pages/publish/form-algorithm.json new file mode 100644 index 000000000..3e85955ba --- /dev/null +++ b/content/pages/publish/form-algorithm.json @@ -0,0 +1,85 @@ +{ + "title": "Publish an Algorithm", + "data": [ + { + "name": "name", + "label": "Title", + "placeholder": "e.g. Shapes of Desert Plants", + "help": "Enter a concise title.", + "required": true + }, + { + "name": "description", + "label": "Description", + "help": "Add a thorough description with as much detail as possible. You can use [Markdown](https://daringfireball.net/projects/markdown/basics).", + "type": "textarea", + "required": true + }, + { + "name": "files", + "label": "File", + "placeholder": "e.g. https://file.com/file.json", + "help": "Please provide a URL to your algorith file. This URL will be stored encrypted after publishing.", + "type": "files", + "required": true + }, + { + "name": "dockerImage", + "label": "Docker Image", + "placeholder": "e.g. python3.7", + "help": "Please select a image to run your algorithm.", + "type": "select", + "options": ["node:pre-defined", "python:pre-defined", "custom image"], + "required": true + }, + { + "name": "image", + "label": "Image URL", + "placeholder": "e.g. oceanprotocol/algo_dockers or https://example.com/image_path", + "help": "Provide the name of a public docker image or the full url if you have it hosted in a 3rd party repo", + "required": false + }, + { + "name": "containerTag", + "label": "Docker Image Tag", + "placeholder": "e.g. latest", + "help": "Provide the tag for your docker image.", + "required": false + }, + { + "name": "entrypoint", + "label": "Entrypoint", + "placeholder": "e.g. python $ALGO", + "help": "Provide the entrypoint for your algorithm.", + "required": false + }, + { + "name": "algorithmPrivacy", + "label": "Algorithm Privacy", + "type": "checkbox", + "options": ["Keep my algorithm private"], + "required": false + }, + { + "name": "author", + "label": "Author", + "placeholder": "e.g. Jelly McJellyfish", + "help": "Give proper attribution for your algorith.", + "required": true + }, + { + "name": "tags", + "label": "Tags", + "placeholder": "e.g. logistics, ai", + "help": "Separate tags with comma." + }, + { + "name": "termsAndConditions", + "label": "Terms & Conditions", + "type": "terms", + "options": ["I agree to these Terms and Conditions"], + "required": true + } + ], + "success": "Algorithm Published!" +} diff --git a/content/pages/publish/form-dataset.json b/content/pages/publish/form-dataset.json new file mode 100644 index 000000000..8e9682778 --- /dev/null +++ b/content/pages/publish/form-dataset.json @@ -0,0 +1,79 @@ +{ + "title": "Publish a Data Set", + "data": [ + { + "name": "name", + "label": "Title", + "placeholder": "e.g. Shapes of Desert Plants", + "help": "Enter a concise title.", + "required": true + }, + { + "name": "description", + "label": "Description", + "help": "Add a thorough description with as much detail as possible. You can use [Markdown](https://daringfireball.net/projects/markdown/basics).", + "type": "textarea", + "required": true + }, + { + "name": "files", + "label": "File", + "placeholder": "e.g. https://file.com/file.json", + "help": "Please provide a URL to your data set file. This URL will be stored encrypted after publishing.", + "type": "files", + "required": true + }, + { + "name": "links", + "label": "Sample file", + "placeholder": "e.g. https://file.com/samplefile.json", + "help": "Please provide a URL to a sample of your data set file. This file should reveal the data structure of your data set, e.g. by including the header and one line of a CSV file. This file URL will be publicly available after publishing.", + "type": "files" + }, + { + "name": "access", + "label": "Access Type", + "help": "Choose how you want your files to be accessible for the specified price.", + "type": "select", + "options": ["Download"], + "required": true + }, + { + "name": "timeout", + "label": "Timeout", + "help": "Define how long buyers should be able to download the data set again after the initial purchase.", + "type": "select", + "options": ["Forever", "1 day", "1 week", "1 month", "1 year"], + "sortOptions": false, + "required": true + }, + { + "name": "dataTokenOptions", + "label": "Datatoken Name & Symbol", + "type": "datatoken", + "help": "The datatoken for this data set will be created with this name & symbol.", + "required": true + }, + { + "name": "author", + "label": "Author", + "placeholder": "e.g. Jelly McJellyfish", + "help": "Give proper attribution for your data set.", + "required": true + }, + { + "name": "tags", + "label": "Tags", + "placeholder": "e.g. logistics, ai", + "help": "Separate tags with comma." + }, + { + "name": "termsAndConditions", + "label": "Terms & Conditions", + "type": "terms", + "options": ["I agree to these Terms and Conditions"], + "required": true + } + ], + "success": "Asset Created!" +} diff --git a/content/pages/publish/index.json b/content/pages/publish/index.json new file mode 100644 index 000000000..00d2d5d91 --- /dev/null +++ b/content/pages/publish/index.json @@ -0,0 +1,5 @@ +{ + "title": "Publish", + "description": "Highlight the important features of your data set or algorithm to make it more discoverable and catch the interest of data consumers.", + "warning": "Given the beta status, publishing on Ropsten or Rinkeby first is strongly recommended. Please familiarize yourself with [the market](https://oceanprotocol.com/technology/marketplaces), [the risks](https://blog.oceanprotocol.com/on-staking-on-data-in-ocean-market-3d8e09eb0a13), and the [Terms of Use](/terms)." +} diff --git a/src/@types/Form.d.ts b/src/@types/Form.d.ts index 107289ca0..5e757c86c 100644 --- a/src/@types/Form.d.ts +++ b/src/@types/Form.d.ts @@ -5,6 +5,7 @@ export interface FormFieldProps { options?: string[] sortOptions?: boolean required?: boolean + disabled?: boolean help?: string placeholder?: string pattern?: string diff --git a/src/@types/MetaData.d.ts b/src/@types/MetaData.d.ts index 6e3a48dea..9ebf613b5 100644 --- a/src/@types/MetaData.d.ts +++ b/src/@types/MetaData.d.ts @@ -24,7 +24,7 @@ export interface PriceOptionsMarket extends PriceOptions { swapFee: number } -export interface MetadataPublishForm { +export interface MetadataPublishFormDataset { // ---- required fields ---- name: string description: string @@ -39,6 +39,22 @@ export interface MetadataPublishForm { links?: string | File[] } +export interface MetadataPublishFormAlgorithm { + // ---- required fields ---- + name: string + description: string + files: string | File[] + author: string + dockerImage: string + algorithmPrivacy: boolean + termsAndConditions: boolean + // ---- optional fields ---- + image: string + containerTag: string + entrypoint: string + tags?: string +} + export interface ServiceMetadataMarket extends ServiceMetadata { attributes: MetadataMarket } diff --git a/src/components/atoms/Tabs.module.css b/src/components/atoms/Tabs.module.css index 1495753a1..3d76fc0d6 100644 --- a/src/components/atoms/Tabs.module.css +++ b/src/components/atoms/Tabs.module.css @@ -13,6 +13,7 @@ text-transform: uppercase; cursor: pointer; color: var(--color-secondary); + background-color: var(--background-body); border: 1px solid var(--border-color); margin-right: -1px; min-width: 100px; @@ -29,7 +30,7 @@ } .tab[aria-selected='true'] { - background: var(--font-color-heading); + background-color: var(--font-color-heading); color: var(--background-body); border-color: var(--font-color-heading); } diff --git a/src/components/molecules/MarketStats.module.css b/src/components/molecules/MarketStats.module.css index 61392a80b..51f06eb70 100644 --- a/src/components/molecules/MarketStats.module.css +++ b/src/components/molecules/MarketStats.module.css @@ -14,5 +14,5 @@ } .info { - width: .85rem -} \ No newline at end of file + width: 0.85rem; +} diff --git a/src/components/molecules/MetadataPreview.module.css b/src/components/molecules/MetadataPreview.module.css index 3cbb49b86..e14eab2a9 100644 --- a/src/components/molecules/MetadataPreview.module.css +++ b/src/components/molecules/MetadataPreview.module.css @@ -1,5 +1,6 @@ .preview { font-size: var(--font-size-small); + margin-top: calc(var(--spacer) / 2); margin-bottom: var(--spacer); } @@ -10,7 +11,14 @@ .metaFull { display: grid; gap: var(--spacer); - grid-template-columns: 1fr 1fr; + grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); +} + +.metaAlgorithm { + display: grid; + gap: var(--spacer); + grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); + margin-bottom: var(--spacer); } .previewTitle { diff --git a/src/components/molecules/MetadataPreview.tsx b/src/components/molecules/MetadataPreview.tsx index cf448987e..d22eb3737 100644 --- a/src/components/molecules/MetadataPreview.tsx +++ b/src/components/molecules/MetadataPreview.tsx @@ -5,7 +5,10 @@ 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 { + MetadataPublishFormDataset, + MetadataPublishFormAlgorithm +} from '../../@types/MetaData' import Button from '../atoms/Button' import { transformTags } from '../../utils/metadata' @@ -42,7 +45,7 @@ function Description({ description }: { description: string }) { ) } -function MetaFull({ values }: { values: Partial }) { +function MetaFull({ values }: { values: Partial }) { return (
{Object.entries(values) @@ -56,6 +59,8 @@ function MetaFull({ values }: { values: Partial }) { key.includes('links') || key.includes('termsAndConditions') || key.includes('dataTokenOptions') || + key.includes('dockerImage') || + key.includes('algorithmPrivacy') || value === undefined || value === '' ) @@ -82,10 +87,10 @@ function Sample({ url }: { url: string }) { ) } -export default function MetadataPreview({ +export function MetadataPreview({ values }: { - values: Partial + values: Partial }): ReactElement { return (
@@ -119,3 +124,47 @@ export default function MetadataPreview({
) } + +export function MetadataAlgorithmPreview({ + values +}: { + values: Partial +}): ReactElement { + return ( +
+

Preview

+
+ {values.name &&

{values.name}

} + {values.description && } + +
+ {values.files?.length > 0 && typeof values.files !== 'string' && ( + + )} +
+ {values.tags && } +
+
+ {values.dockerImage && ( + + )} + {values.algorithmPrivacy && ( + + )} +
+ +
+ ) +} diff --git a/src/components/organisms/AssetActions/Edit/Debug.tsx b/src/components/organisms/AssetActions/Edit/Debug.tsx index f8a973dbd..b08f584d0 100644 --- a/src/components/organisms/AssetActions/Edit/Debug.tsx +++ b/src/components/organisms/AssetActions/Edit/Debug.tsx @@ -1,6 +1,6 @@ import { DDO } from '@oceanprotocol/lib' import React, { ReactElement } from 'react' -import { MetadataPublishForm } from '../../../../@types/MetaData' +import { MetadataPublishFormDataset } from '../../../../@types/MetaData' import { transformPublishFormToMetadata } from '../../../../utils/metadata' import DebugOutput from '../../../atoms/DebugOutput' @@ -8,7 +8,7 @@ export default function Debug({ values, ddo }: { - values: Partial + values: Partial ddo: DDO }): ReactElement { const newDdo = { diff --git a/src/components/organisms/AssetActions/Edit/FormEditMetadata.tsx b/src/components/organisms/AssetActions/Edit/FormEditMetadata.tsx index 238b4cd24..5e62b59f2 100644 --- a/src/components/organisms/AssetActions/Edit/FormEditMetadata.tsx +++ b/src/components/organisms/AssetActions/Edit/FormEditMetadata.tsx @@ -5,12 +5,12 @@ 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' +import { MetadataPublishFormDataset } from '../../../../@types/MetaData' import { checkIfTimeoutInPredefinedValues } from '../../../../utils/metadata' function handleTimeoutCustomOption( data: FormFieldProps[], - values: Partial + values: Partial ) { const timeoutFieldContent = data.filter( (field) => field.name === 'timeout' @@ -51,14 +51,14 @@ export default function FormEditMetadata({ data: FormFieldProps[] setShowEdit: (show: boolean) => void setTimeoutStringValue: (value: string) => void - values: Partial + values: Partial }): ReactElement { const { ocean, accountId } = useOcean() const { isValid, validateField, setFieldValue - }: FormikContextType> = useFormikContext() + }: FormikContextType> = useFormikContext() // Manually handle change events instead of using `handleChange` from Formik. // Workaround for default `validateOnChange` not kicking in diff --git a/src/components/organisms/AssetActions/Edit/index.tsx b/src/components/organisms/AssetActions/Edit/index.tsx index feb609469..77cf23acc 100644 --- a/src/components/organisms/AssetActions/Edit/index.tsx +++ b/src/components/organisms/AssetActions/Edit/index.tsx @@ -1,14 +1,14 @@ import { useOcean } from '@oceanprotocol/react' import { Formik } from 'formik' import React, { ReactElement, useState } from 'react' -import { MetadataPublishForm } from '../../../../@types/MetaData' +import { MetadataPublishFormDataset } 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 { MetadataPreview } from '../../../molecules/MetadataPreview' import Debug from './Debug' import Web3Feedback from '../../../molecules/Wallet/Feedback' import FormEditMetadata from './FormEditMetadata' @@ -66,7 +66,7 @@ export default function Edit({ const hasFeedback = error || success async function handleSubmit( - values: Partial, + values: Partial, resetForm: () => void ) { try { diff --git a/src/components/organisms/AssetActions/index.tsx b/src/components/organisms/AssetActions/index.tsx index 3d1c38f12..cf2084ea4 100644 --- a/src/components/organisms/AssetActions/index.tsx +++ b/src/components/organisms/AssetActions/index.tsx @@ -3,7 +3,6 @@ import styles from './index.module.css' import Compute from './Compute' import Consume from './Consume' import { Logger } from '@oceanprotocol/lib' -import { ConfigHelperConfig } from '@oceanprotocol/lib/dist/node/utils/ConfigHelper' import Tabs from '../../atoms/Tabs' import { useOcean } from '@oceanprotocol/react' import compareAsBN from '../../../utils/compareAsBN' diff --git a/src/components/organisms/AssetContent/MetaFull.module.css b/src/components/organisms/AssetContent/MetaFull.module.css index bdb4e01ea..455e8ef86 100644 --- a/src/components/organisms/AssetContent/MetaFull.module.css +++ b/src/components/organisms/AssetContent/MetaFull.module.css @@ -2,7 +2,7 @@ margin-top: var(--spacer); display: grid; gap: var(--spacer); - grid-template-columns: 1fr 1fr; + grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); } .metaFull code { diff --git a/src/components/organisms/AssetContent/MetaItem.module.css b/src/components/organisms/AssetContent/MetaItem.module.css index a01955170..d74d3dd5c 100644 --- a/src/components/organisms/AssetContent/MetaItem.module.css +++ b/src/components/organisms/AssetContent/MetaItem.module.css @@ -10,3 +10,8 @@ color: var(--color-secondary); text-transform: uppercase; } + +.content { + word-wrap: break-word; + white-space: normal; +} diff --git a/src/components/organisms/AssetContent/MetaItem.tsx b/src/components/organisms/AssetContent/MetaItem.tsx index 5bb4275e2..8fc36ba4c 100644 --- a/src/components/organisms/AssetContent/MetaItem.tsx +++ b/src/components/organisms/AssetContent/MetaItem.tsx @@ -11,7 +11,7 @@ export default function MetaItem({ return (

{title}

- {content} +
{content}
) } diff --git a/src/components/pages/Publish/Debug.tsx b/src/components/pages/Publish/Debug.tsx index 31e5404ac..2aa2375e4 100644 --- a/src/components/pages/Publish/Debug.tsx +++ b/src/components/pages/Publish/Debug.tsx @@ -1,5 +1,5 @@ import React, { ReactElement } from 'react' -import { MetadataPublishForm } from '../../../@types/MetaData' +import { MetadataPublishFormDataset } from '../../../@types/MetaData' import DebugOutput from '../../atoms/DebugOutput' import styles from './index.module.css' import { transformPublishFormToMetadata } from '../../../utils/metadata' @@ -7,7 +7,7 @@ import { transformPublishFormToMetadata } from '../../../utils/metadata' export default function Debug({ values }: { - values: Partial + values: Partial }): ReactElement { const ddo = { '@context': 'https://w3id.org/did/v1', diff --git a/src/components/pages/Publish/FormAlgoPublish.tsx b/src/components/pages/Publish/FormAlgoPublish.tsx new file mode 100644 index 000000000..18af97ab8 --- /dev/null +++ b/src/components/pages/Publish/FormAlgoPublish.tsx @@ -0,0 +1,162 @@ +import React, { + ReactElement, + useEffect, + useState, + FormEvent, + ChangeEvent +} from 'react' +import { useStaticQuery, graphql } from 'gatsby' +import styles from './FormPublish.module.css' +import { useOcean } from '@oceanprotocol/react' +import { useFormikContext, Field, Form, FormikContextType } from 'formik' +import Input from '../../atoms/Input' +import Button from '../../atoms/Button' +import { FormContent, FormFieldProps } from '../../../@types/Form' +import { MetadataPublishFormAlgorithm } from '../../../@types/MetaData' +import { initialValues as initialValuesAlgorithm } from '../../../models/FormAlgoPublish' + +import stylesIndex from './index.module.css' + +const query = graphql` + query { + content: allFile( + filter: { relativePath: { eq: "pages/publish/form-algorithm.json" } } + ) { + edges { + node { + childPublishJson { + title + data { + name + placeholder + label + help + type + required + sortOptions + options + } + warning + } + } + } + } + } +` + +export default function FormPublish(): ReactElement { + const data = useStaticQuery(query) + const content: FormContent = data.content.edges[0].node.childPublishJson + const { ocean, account } = useOcean() + const { + status, + setStatus, + isValid, + setErrors, + setTouched, + resetForm, + initialValues, + validateField, + setFieldValue + }: FormikContextType = useFormikContext() + const [selectedDockerImage, setSelectedDockerImage] = useState( + initialValues.dockerImage + ) + // reset form validation on every mount + useEffect(() => { + setErrors({}) + setTouched({}) + + // setSubmitting(false) + }, [setErrors, setTouched]) + + function handleImageSelectChange(imageSelected: string) { + switch (imageSelected) { + case 'node:pre-defined': { + setFieldValue('image', 'node') + setFieldValue('containerTag', '10') + setFieldValue('entrypoint', 'node $ALGO') + break + } + case 'python:pre-defined': { + setFieldValue('image', 'oceanprotocol/algo_dockers') + setFieldValue('containerTag', 'python-panda') + setFieldValue('entrypoint', 'python $ALGO') + break + } + default: { + setFieldValue('image', '') + setFieldValue('containerTag', '') + setFieldValue('entrypoint', '') + break + } + } + } + + // Manually handle change events instead of using `handleChange` from Formik. + // Workaround for default `validateOnChange` not kicking in + function handleFieldChange( + e: ChangeEvent, + field: FormFieldProps + ) { + const value = + field.type === 'checkbox' ? !JSON.parse(e.target.value) : e.target.value + if (field.name === 'dockerImage') { + setSelectedDockerImage(e.target.value) + handleImageSelectChange(e.target.value) + } + validateField(field.name) + setFieldValue(field.name, value) + } + + const resetFormAndClearStorage = (e: FormEvent) => { + e.preventDefault() + resetForm({ + values: initialValuesAlgorithm as MetadataPublishFormAlgorithm, + status: 'empty' + }) + setStatus('empty') + } + + return ( +
status === 'empty' && setStatus(null)} + > +

{content.title}

+ {content.data.map( + (field: FormFieldProps) => + ((field.name !== 'entrypoint' && + field.name !== 'image' && + field.name !== 'containerTag') || + selectedDockerImage === 'custom image') && ( + ) => + handleFieldChange(e, field) + } + /> + ) + )} + +
+ + + {status !== 'empty' && ( + + )} +
+ + ) +} diff --git a/src/components/pages/Publish/FormPublish.module.css b/src/components/pages/Publish/FormPublish.module.css index 668d286a3..6b4b09367 100644 --- a/src/components/pages/Publish/FormPublish.module.css +++ b/src/components/pages/Publish/FormPublish.module.css @@ -1,6 +1,9 @@ .form { composes: box from '../../atoms/Box.module.css'; margin-bottom: var(--spacer); + border-top: none; + border-top-left-radius: 0; + border-top-right-radius: 0; } .actions { diff --git a/src/components/pages/Publish/FormPublish.tsx b/src/components/pages/Publish/FormPublish.tsx index 80fe85c81..5ec54b0ee 100644 --- a/src/components/pages/Publish/FormPublish.tsx +++ b/src/components/pages/Publish/FormPublish.tsx @@ -1,17 +1,45 @@ import React, { ReactElement, useEffect, FormEvent, ChangeEvent } from 'react' +import { useStaticQuery, graphql } from 'gatsby' import styles from './FormPublish.module.css' import { useOcean } from '@oceanprotocol/react' import { useFormikContext, Field, Form, FormikContextType } from 'formik' import Input from '../../atoms/Input' import Button from '../../atoms/Button' import { FormContent, FormFieldProps } from '../../../@types/Form' -import { MetadataPublishForm } from '../../../@types/MetaData' +import { MetadataPublishFormDataset } from '../../../@types/MetaData' +import { initialValues as initialValuesDataset } from '../../../models/FormAlgoPublish' +import stylesIndex from './index.module.css' -export default function FormPublish({ - content -}: { - content: FormContent -}): ReactElement { +const query = graphql` + query { + content: allFile( + filter: { relativePath: { eq: "pages/publish/form-dataset.json" } } + ) { + edges { + node { + childPublishJson { + title + data { + name + placeholder + label + help + type + required + sortOptions + options + } + warning + } + } + } + } + } +` + +export default function FormPublish(): ReactElement { + const data = useStaticQuery(query) + const content: FormContent = data.content.edges[0].node.childPublishJson const { ocean, account } = useOcean() const { status, @@ -23,7 +51,7 @@ export default function FormPublish({ initialValues, validateField, setFieldValue - }: FormikContextType = useFormikContext() + }: FormikContextType = useFormikContext() // reset form validation on every mount useEffect(() => { @@ -45,7 +73,10 @@ export default function FormPublish({ const resetFormAndClearStorage = (e: FormEvent) => { e.preventDefault() - resetForm({ values: initialValues, status: 'empty' }) + resetForm({ + values: initialValuesDataset as MetadataPublishFormDataset, + status: 'empty' + }) setStatus('empty') } @@ -55,6 +86,7 @@ export default function FormPublish({ // do we need this? onChange={() => status === 'empty' && setStatus(null)} > +

{content.title}

{content.data.map((field: FormFieldProps) => ( > -}): ReactElement { - useEffect(() => { - setType(publishTypes[0].value) - }, []) - - return ( -
- {publishTypes.map((e, index) => { - const tabElement = cx({ - [styles.selected]: e.value === type, - [styles.tabElement]: true - }) - return ( - - ) - })} -
- ) -} diff --git a/src/components/pages/Publish/index.module.css b/src/components/pages/Publish/index.module.css index 4a6d8a9df..fe63517cf 100644 --- a/src/components/pages/Publish/index.module.css +++ b/src/components/pages/Publish/index.module.css @@ -1,3 +1,16 @@ +.tabs ul[class*='tabList'] { + background-color: var(--background-content); + border: 1px solid var(--border-color); + border-top-left-radius: var(--border-radius); + border-top-right-radius: var(--border-radius); +} + +.tabs div[class*='tabContent'] { + padding-left: 0; + padding-right: 0; + padding-top: 0; +} + .grid { display: grid; gap: calc(var(--spacer) * 1.5); @@ -16,8 +29,17 @@ div.alert { grid-template-columns: 1.618fr 1fr; } + .tabs ul[class*='tabList'] { + /* fake the above 1.618fr column */ + max-width: calc((100% / 1.618) - calc(var(--spacer) / 1.075)); + } + .sticky { position: sticky; top: calc(var(--spacer) / 2); } } + +.formTitle { + font-size: var(--font-size-h4); +} diff --git a/src/components/pages/Publish/index.tsx b/src/components/pages/Publish/index.tsx index a565449d8..b543ef0e5 100644 --- a/src/components/pages/Publish/index.tsx +++ b/src/components/pages/Publish/index.tsx @@ -1,46 +1,111 @@ -import React, { ReactElement, useState } from 'react' -import { Formik } from 'formik' +import React, { ReactElement, useState, useEffect } from 'react' +import { Formik, FormikState } from 'formik' import { usePublish, useOcean } from '@oceanprotocol/react' import styles from './index.module.css' import FormPublish from './FormPublish' -import PublishType from './PublishType' +import FormAlgoPublish from './FormAlgoPublish' import Web3Feedback from '../../molecules/Wallet/Feedback' -import { FormContent } from '../../../@types/Form' +import Tabs from '../../atoms/Tabs' import { initialValues, validationSchema } from '../../../models/FormPublish' +import { + initialValues as initialValuesAlgorithm, + validationSchema as validationSchemaAlgorithm +} from '../../../models/FormAlgoPublish' import { transformPublishFormToMetadata, - mapTimeoutStringToSeconds + transformPublishAlgorithmFormToMetadata, + mapTimeoutStringToSeconds, + validateDockerImage } from '../../../utils/metadata' -import MetadataPreview from '../../molecules/MetadataPreview' -import { MetadataPublishForm } from '../../../@types/MetaData' +import { + MetadataPreview, + MetadataAlgorithmPreview +} from '../../molecules/MetadataPreview' +import { + MetadataPublishFormDataset, + MetadataPublishFormAlgorithm +} from '../../../@types/MetaData' import { useUserPreferences } from '../../../providers/UserPreferences' -import { Logger, Metadata } from '@oceanprotocol/lib' +import { Logger, Metadata, MetadataMain } from '@oceanprotocol/lib' import { Persist } from '../../atoms/FormikPersist' import Debug from './Debug' import Alert from '../../atoms/Alert' import MetadataFeedback from '../../molecules/MetadataFeedback' -import Button from '../../atoms/Button' -const formName = 'ocean-publish-form' +const formNameDatasets = 'ocean-publish-form-datasets' +const formNameAlgorithms = 'ocean-publish-form-algorithms' + +function TabContent({ + publishType, + values +}: { + publishType: MetadataMain['type'] + values: Partial +}) { + return ( +
+ {publishType === 'dataset' ? : } + + +
+ ) +} export default function PublishPage({ content }: { - content: { warning: string; form: FormContent } + content: { warning: string } }): ReactElement { const { debug } = useUserPreferences() const { publish, publishError, isLoading, publishStepText } = usePublish() const { isInPurgatory, purgatoryData } = useOcean() const [success, setSuccess] = useState() const [error, setError] = useState() + const [title, setTitle] = useState() const [did, setDid] = useState() - const [publishType, setPublishType] = useState() + const [algoInitialValues, setAlgoInitialValues] = useState< + Partial + >( + (localStorage.getItem('ocean-publish-form-algorithms') && + (JSON.parse(localStorage.getItem('ocean-publish-form-algorithms')) + .initialValues as MetadataPublishFormAlgorithm)) || + initialValuesAlgorithm + ) + const [datasetInitialValues, setdatasetInitialValues] = useState< + Partial + >( + (localStorage.getItem('ocean-publish-form-datasets') && + (JSON.parse(localStorage.getItem('ocean-publish-form-datasets')) + .initialValues as MetadataPublishFormDataset)) || + initialValues + ) + const [publishType, setPublishType] = useState( + 'dataset' + ) const hasFeedback = isLoading || error || success + useEffect(() => { + publishType === 'dataset' + ? setTitle('Publishing Data Set') + : setTitle('Publishing Algorithm') + }, [publishType]) + async function handleSubmit( - values: Partial, - resetForm: () => void + values: Partial, + resetForm: ( + nextState?: Partial>> + ) => void ): Promise { const metadata = transformPublishFormToMetadata(values) const timeout = mapTimeoutStringToSeconds(values.timeout) @@ -74,7 +139,53 @@ export default function PublishPage({ setSuccess( '🎉 Successfully published. 🎉 Now create a price on your data set.' ) - resetForm() + resetForm({ + values: initialValues as MetadataPublishFormDataset, + status: 'empty' + }) + } catch (error) { + setError(error.message) + Logger.error(error.message) + } + } + + async function handleAlgorithmSubmit( + values: Partial, + resetForm: ( + nextState?: Partial>> + ) => void + ): Promise { + const metadata = transformPublishAlgorithmFormToMetadata(values) + const validDockerImage = + values.dockerImage === 'custom image' + ? await validateDockerImage(values.image, values.containerTag) + : true + try { + if (validDockerImage) { + Logger.log('Publish Algorithm with ', metadata) + + const ddo = await publish( + (metadata as unknown) as Metadata, + values.algorithmPrivacy === true ? 'compute' : 'access' + ) + + // Publish failed + if (!ddo || publishError) { + setError(publishError || 'Publishing DDO failed.') + Logger.error(publishError || 'Publishing DDO failed.') + return + } + + // Publish succeeded + setDid(ddo.id) + setSuccess( + '🎉 Successfully published. 🎉 Now create a price for your algorithm.' + ) + resetForm({ + values: initialValuesAlgorithm as MetadataPublishFormAlgorithm, + status: 'empty' + }) + } } catch (error) { setError(error.message) Logger.error(error.message) @@ -83,56 +194,83 @@ export default function PublishPage({ return isInPurgatory && purgatoryData ? null : ( { // move user's focus to top of screen window.scrollTo({ top: 0, left: 0, behavior: 'smooth' }) // kick off publishing - await handleSubmit(values, resetForm) + publishType === 'dataset' + ? await handleSubmit(values, resetForm) + : await handleAlgorithmSubmit(values, resetForm) }} + enableReinitialize > - {({ values }) => ( - <> - + {({ values }) => { + const tabs = [ + { + title: 'Data Set', + content: + }, + { + title: 'Algorithm', + content: + } + ] - {hasFeedback ? ( - + - ) : ( - <> - - -
- + ) : ( + <> + - -
- - )} + { + setPublishType(title.toLowerCase().replace(' ', '') as any) + title === 'Algorithm' + ? setdatasetInitialValues(values) + : setAlgoInitialValues(values) + }} + /> + + )} - {debug === true && } - - )} + {debug === true && } + + ) + }}
) } diff --git a/src/components/templates/Search/filterPrice.module.css b/src/components/templates/Search/filterPrice.module.css index 39453385e..c5d3ed19d 100644 --- a/src/components/templates/Search/filterPrice.module.css +++ b/src/components/templates/Search/filterPrice.module.css @@ -43,6 +43,10 @@ button.filter, color: var(--background-body); } +.filterList:first-of-type { + margin-bottom: calc(var(--spacer) / 6); +} + .showClear { display: inline-flex; text-transform: capitalize; diff --git a/src/models/FormAlgoPublish.ts b/src/models/FormAlgoPublish.ts new file mode 100644 index 000000000..44214b1be --- /dev/null +++ b/src/models/FormAlgoPublish.ts @@ -0,0 +1,42 @@ +import { MetadataPublishFormAlgorithm } from '../@types/MetaData' +import { File as FileMetadata } from '@oceanprotocol/lib' +import * as Yup from 'yup' + +export const validationSchema: Yup.SchemaOf = Yup.object() + .shape({ + // ---- required fields ---- + name: Yup.string() + .min(4, (param) => `Title must be at least ${param.min} characters`) + .required('Required'), + description: Yup.string().min(10).required('Required'), + files: Yup.array().required('Required').nullable(), + dockerImage: Yup.string() + .matches(/node:pre-defined|python:pre-defined|custom image/g, { + excludeEmptyString: true + }) + .required('Required'), + image: Yup.string().required('Required'), + containerTag: Yup.string().required('Required'), + entrypoint: Yup.string().required('Required'), + author: Yup.string().required('Required'), + termsAndConditions: Yup.boolean().required('Required'), + // ---- optional fields ---- + algorithmPrivacy: Yup.boolean().nullable(), + tags: Yup.string().nullable(), + links: Yup.array().nullable() + }) + .defined() + +export const initialValues: Partial = { + name: '', + author: '', + dockerImage: 'node:pre-defined', + image: 'node', + containerTag: '10', + entrypoint: 'node $ALGO', + files: '', + description: '', + algorithmPrivacy: false, + termsAndConditions: false, + tags: '' +} diff --git a/src/models/FormEditMetadata.ts b/src/models/FormEditMetadata.ts index b98b6c488..1ddf93b09 100644 --- a/src/models/FormEditMetadata.ts +++ b/src/models/FormEditMetadata.ts @@ -1,4 +1,4 @@ -import { MetadataMarket, MetadataPublishForm } from '../@types/MetaData' +import { MetadataMarket, MetadataPublishFormDataset } from '../@types/MetaData' import { secondsToString } from '../utils/metadata' import * as Yup from 'yup' @@ -13,7 +13,7 @@ export const validationSchema = Yup.object().shape({ export function getInitialValues( metadata: MetadataMarket, timeout: number -): Partial { +): Partial { return { name: metadata.main.name, description: metadata.additionalInformation.description, diff --git a/src/models/FormPublish.ts b/src/models/FormPublish.ts index 64bd63ee1..c6387eab0 100644 --- a/src/models/FormPublish.ts +++ b/src/models/FormPublish.ts @@ -1,8 +1,8 @@ -import { MetadataPublishForm } from '../@types/MetaData' +import { MetadataPublishFormDataset } from '../@types/MetaData' import { File as FileMetadata } from '@oceanprotocol/lib' import * as Yup from 'yup' -export const validationSchema: Yup.SchemaOf = Yup.object() +export const validationSchema: Yup.SchemaOf = Yup.object() .shape({ // ---- required fields ---- name: Yup.string() @@ -29,7 +29,7 @@ export const validationSchema: Yup.SchemaOf = Yup.object() }) .defined() -export const initialValues: Partial = { +export const initialValues: Partial = { name: '', author: '', dataTokenOptions: { diff --git a/src/pages/publish.tsx b/src/pages/publish.tsx index 97a4c358e..02f7d47da 100644 --- a/src/pages/publish.tsx +++ b/src/pages/publish.tsx @@ -4,7 +4,7 @@ import Page from '../components/templates/Page' import { graphql, PageProps } from 'gatsby' export default function PageGatsbyPublish(props: PageProps): ReactElement { - const content = (props.data as any).content.edges[0].node.childPagesJson + const content = (props.data as any).content.edges[0].node.childPublishJson const { title, description } = content return ( @@ -16,27 +16,15 @@ export default function PageGatsbyPublish(props: PageProps): ReactElement { export const contentQuery = graphql` query PublishPageQuery { - content: allFile(filter: { relativePath: { eq: "pages/publish.json" } }) { + content: allFile( + filter: { relativePath: { eq: "pages/publish/index.json" } } + ) { edges { node { - childPagesJson { + childPublishJson { title description warning - form { - title - data { - name - placeholder - label - help - type - required - sortOptions - options - } - success - } } } } diff --git a/src/utils/metadata.ts b/src/utils/metadata.ts index 23bf00406..36e491c4d 100644 --- a/src/utils/metadata.ts +++ b/src/utils/metadata.ts @@ -1,8 +1,15 @@ -import { MetadataMarket, MetadataPublishForm } from '../@types/MetaData' +import axios, { CancelToken, AxiosResponse } from 'axios' +import { toast } from 'react-toastify' +import isUrl from 'is-url-superb' +import { + MetadataMarket, + MetadataPublishFormDataset, + MetadataPublishFormAlgorithm +} from '../@types/MetaData' import { toStringNoMS } from '.' import AssetModel from '../models/Asset' import slugify from '@sindresorhus/slugify' -import { DDO } from '@oceanprotocol/lib' +import { DDO, MetadataAlgorithm, Logger } from '@oceanprotocol/lib' export function transformTags(value: string): string[] { const originalTags = value?.split(',') @@ -66,6 +73,29 @@ export function checkIfTimeoutInPredefinedValues( return false } +function getAlgoithComponent( + image: string, + containerTag: string, + entrypoint: string, + algorithmLanguace: string +): MetadataAlgorithm { + return { + language: algorithmLanguace, + format: 'docker-image', + version: '0.1', + container: { + entrypoint: entrypoint, + image: image, + tag: containerTag + } + } +} + +function getAlgoithFileExtension(fileUrl: string): string { + const splitedFileUrl = fileUrl.split('.') + return splitedFileUrl[splitedFileUrl.length - 1] +} + export function transformPublishFormToMetadata( { name, @@ -75,7 +105,7 @@ export function transformPublishFormToMetadata( links, termsAndConditions, files - }: Partial, + }: Partial, ddo?: DDO ): MetadataMarket { const currentTime = toStringNoMS(new Date()) @@ -100,3 +130,103 @@ export function transformPublishFormToMetadata( return metadata } + +async function isDockerHubImageValid( + image: string, + tag: string +): Promise { + try { + const response = await axios.get( + `https://hub.docker.com/v2/repositories/${image}/tags/${tag}` + ) + if (!response || response.status !== 200 || !response.data) { + toast.error( + 'Could not fetch docker hub image info. Please check image name and tag and try again' + ) + return false + } + + return true + } catch (error) { + Logger.error(error.message) + toast.error( + 'Could not fetch docker hub image info. Please check image name and tag and try again' + ) + return false + } +} + +async function is3rdPartyImageValid(imageURL: string): Promise { + try { + const response = await axios.head(imageURL) + if (!response || response.status !== 200) { + toast.error( + 'Could not fetch docker image info. Please check URL and try again' + ) + return false + } + return true + } catch (error) { + Logger.error(error.message) + toast.error( + 'Could not fetch docker image info. Please check URL and try again' + ) + return false + } +} + +export async function validateDockerImage( + dockerImage: string, + tag: string +): Promise { + const isValid = isUrl(dockerImage) + ? await is3rdPartyImageValid(dockerImage) + : await isDockerHubImageValid(dockerImage, tag) + return isValid +} + +export function transformPublishAlgorithmFormToMetadata( + { + name, + author, + description, + tags, + dockerImage, + image, + containerTag, + entrypoint, + termsAndConditions, + files + }: Partial, + ddo?: DDO +): MetadataMarket { + const currentTime = toStringNoMS(new Date()) + const fileUrl = typeof files !== 'string' && files[0].url + const algorithmLanguace = getAlgoithFileExtension(fileUrl) + const algorithm = getAlgoithComponent( + image, + containerTag, + entrypoint, + algorithmLanguace + ) + const metadata: MetadataMarket = { + main: { + ...AssetModel.main, + name, + type: 'algorithm', + author, + dateCreated: ddo ? ddo.created : currentTime, + files: typeof files !== 'string' && files, + license: 'https://market.oceanprotocol.com/terms', + algorithm: algorithm + }, + additionalInformation: { + ...AssetModel.additionalInformation, + description, + tags: transformTags(tags), + termsAndConditions + } + } + + return metadata +} diff --git a/tests/unit/__fixtures__/testFormData.ts b/tests/unit/__fixtures__/testFormData.ts index 35d5b5439..8ff2d7342 100644 --- a/tests/unit/__fixtures__/testFormData.ts +++ b/tests/unit/__fixtures__/testFormData.ts @@ -1,6 +1,6 @@ -import { MetadataPublishForm } from '../../../src/@types/MetaData' +import { MetadataPublishFormDataset } from '../../../src/@types/MetaData' -const testFormData: MetadataPublishForm = { +const testFormData: MetadataPublishFormDataset = { author: '', files: [], dataTokenOptions: { diff --git a/tests/unit/components/PublishForm.test.tsx b/tests/unit/components/PublishForm.test.tsx index 758e168cb..f520481f0 100644 --- a/tests/unit/components/PublishForm.test.tsx +++ b/tests/unit/components/PublishForm.test.tsx @@ -3,20 +3,19 @@ import { render } from '@testing-library/react' import { transformPublishFormToMetadata } from '../../../src/utils/metadata' import { MetadataMarket, - MetadataPublishForm + MetadataPublishFormDataset } from '../../../src/@types/MetaData' import PublishForm from '../../../src/components/pages/Publish/FormPublish' import publishFormData from '../__fixtures__/testFormData' -import content from '../../../content/pages/publish.json' describe('PublishForm', () => { it('renders without crashing', async () => { - const { container } = render() + const { container } = render() expect(container.firstChild).toBeInTheDocument() }) // it('Form data is correctly transformed to asset Metadata', () => { - // const data: MetadataPublishForm = publishFormData + // const data: MetadataPublishFormDataset = publishFormData // let metadata: MetadataMarket = transformPublishFormToMetadata(data) // expect(metadata.additionalInformation).toBeDefined() diff --git a/tests/unit/pages/publish.test.tsx b/tests/unit/pages/publish.test.tsx index b2fcd1ee2..387ea5bb1 100644 --- a/tests/unit/pages/publish.test.tsx +++ b/tests/unit/pages/publish.test.tsx @@ -1,7 +1,7 @@ import React from 'react' import { render } from '@testing-library/react' import Publish from '../../../src/components/pages/Publish' -import content from '../../../content/pages/publish.json' +import content from '../../../content/pages/publish/index.json' describe('Home', () => { it('renders without crashing', () => {