Feat: Consumer Parameters (#1921)

* feat: add consumer parameters to publish

* fix: publish form validation

* feat: update consumer parameters form

* feat: add consumer parameter types

* feat: update consumer parameter validation

* feat: add consumer parameters structure

* feat: add InputOptions error handling

* feat: update consumer parameters validation

* feat: update transformPublishFromToDdo

* feat: add default options to parameter options

* fix: value handling for select and mutliselect

* feat: update "add new parameter" button label

* feat: remove unused publish form sections

* chore: remove console.log

* chore: remove comments

* feat: remove multiselect

* feat: add consumer parameters section label

* feat: update types

* feat: update edit form fields

* feat: parse consumer parameters in edit form

* feat: add consumer parameters field to edit

* feat: transform consumer parameters values before edit

* feat: update "required" type to select

* feat: update "required" type to select on edit form

* fix: error object access

* fix: edit flow crash

* fix: validation when consumer parameters are not selected

* feat: update validation for default consumer parameters value

* fix: types

* feat: add service consumer parameters to publish form

* chore: remove console.log

* feat: add service consumer parameters edit

* chore: remove comments

* fix: form edit metadata types

* feat: add consume algo parameter structure

* feat: consumer parameter required default value condition

* feat: add consumer parameter groups to assetActions

* fix: consumer parameters grouping

* feat: update consumer parameters alignment

* feat: update types

* feat: allow service consumer parameters on dataset asset type

* feat: add consumer parameters to ddo updated values in edit form

* feat: add "data service" consumer parameters to consumption form

* feat: allow service parameters on all asset types

* feat: update parameter consume form design

* feat: update asset actions consume parameters location

* feat: add service parameters to download assets

* refactor: consumer parameters actions

* refactor: form action name

* refactor: consumer parameters default input

* refactor: consumer parameters select input

* refactor: consumer parameter input

* fix: props name

* refactor: variable naming

* refactor: consumer parameters form validation

* refactor: rename consumer parameters types

* refactor: extract consumer parameters form data to separate file

* refactor: rename type

* feat: controlled tabs for consumer params

* feat: restore default value as required

* refactor: parse values before edit

* feat: add form to handle consumer parameters consumption

* feat: send consumer params with download request

* feat: send consumer params with compute request

* feat: handle compute form initialization

* chore: remove unused dependency

* feat: handle download form data initialization

* chore: remove console.log

* feat: update types

* fix: consumer parameter value types

* feat: update ConsumerParameter type

* feat: update ConsumerParameter type

* chore: add comments

* refactor: consumer parameters inputs

* refactor: rename data and algo service params

* refactor: consumer parameters form

* refactor: consumer parameter form styling

* refactor: make headers input reusable as KeyValueInput

* refactor: refactorings, reduce duplication (WIP)

* refactor: usercustomparameters consumption form creation/validation

* refactor: return undefined consistently if property path not found on object

* refactor: reuse fieldType and fieldOptions in DefaultInput

* fix: parse ddo consumer parameters for edit form

* fix: asset view crash for assets w/o consumer params

* fix: publish preview for assets with no consumer params

* fix: revert accidential rename of algoService

* fix: revert publish navigation padding change

* feat: update consumer parameters' labels and helpers

* fix: consumer parameters validation

* feat: update consumer parameter helper wording

* chore: merge conflicts

* feat: update types

* fix: validate form on consumer parameter deletion

* feat: add key value input placeholder props

* fix: handle boolean type consumer parameters in consume form (#38)

* Update src/components/Publish/_validation.ts

Co-authored-by: Jamie Hewitt <jamie.hewitt15@gmail.com>

* Update src/components/Asset/AssetActions/ConsumerParameters/FormConsumerParameters.module.css

Co-authored-by: Jamie Hewitt <jamie.hewitt15@gmail.com>

* Update src/components/Asset/Edit/_validation.ts

Co-authored-by: Jamie Hewitt <jamie.hewitt15@gmail.com>

* Update src/components/Asset/AssetActions/ConsumerParameters/index.tsx

Co-authored-by: Jamie Hewitt <jamie.hewitt15@gmail.com>

* Update src/components/Asset/AssetActions/ConsumerParameters/FormConsumerParameters.tsx

Co-authored-by: Jamie Hewitt <jamie.hewitt15@gmail.com>

* Update src/components/@shared/FormInput/index.tsx

Co-authored-by: Jamie Hewitt <jamie.hewitt15@gmail.com>

* Update src/components/@shared/FormInput/InputElement/ConsumerParameters/index.tsx

Co-authored-by: Jamie Hewitt <jamie.hewitt15@gmail.com>

* Update src/components/@shared/FormInput/InputElement/ConsumerParameters/index.tsx

Co-authored-by: Jamie Hewitt <jamie.hewitt15@gmail.com>

* Update src/components/@shared/FormInput/InputElement/ConsumerParameters/OptionsInput.tsx

Co-authored-by: Jamie Hewitt <jamie.hewitt15@gmail.com>

* Update src/components/@shared/FormInput/InputElement/ConsumerParameters/FormActions.tsx

Co-authored-by: Jamie Hewitt <jamie.hewitt15@gmail.com>

* Update src/@utils/provider.ts

Co-authored-by: Jamie Hewitt <jamie.hewitt15@gmail.com>

* fix: import paths

* refactor: add consumer parameters validation types

* feat: add string length min/max to consumer parameters validation

* feat: add cp description to consume form

* feat: reduce character limit in CP tab title

* feat: update CP fields' placeholders and helpers

* fix: show only relevant CPs in asset actions

---------

Co-authored-by: Luca Milanese <luca.milanese90@gmail.com>
Co-authored-by: Jamie Hewitt <jamie.hewitt15@gmail.com>
This commit is contained in:
Moritz Kirstein 2023-09-07 16:14:27 +02:00 committed by GitHub
parent b7a28df97e
commit fefb42aa07
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
48 changed files with 1415 additions and 156 deletions

View File

@ -179,6 +179,14 @@
"placeholder": "e.g. logistics",
"required": false
},
{
"name": "usesConsumerParameters",
"label": "Algorithm custom parameters",
"help": "Algorithm custom parameters are used to define required consumer input before running the algorithm in a Compute-to-Data environment.",
"type": "checkbox",
"options": ["This asset uses algorithm custom parameters"],
"required": false
},
{
"name": "paymentCollector",
"label": "Payment Collector Address",
@ -199,6 +207,14 @@
],
"sortOptions": false,
"required": false
},
{
"name": "usesServiceConsumerParameters",
"label": "User defined parameters",
"help": "User defined parameters are used to filter or query the published asset.",
"type": "checkbox",
"options": ["This asset uses user defined parameters"],
"required": false
}
]
}

View File

@ -0,0 +1,66 @@
{
"consumerParameters": {
"fields": [
{
"name": "consumerParameters",
"label": "Custom parameters",
"type": "consumerParameters",
"required": false,
"fields": [
{
"name": "name",
"label": "Parameter Name",
"placeholder": "e.g. iterations",
"help": "The parameter name (this is sent as HTTP param or key towards algo).",
"type": "text",
"required": true
},
{
"name": "label",
"label": "Parameter Label",
"placeholder": "e.g. Iterations",
"help": "The field label which is displayed.",
"type": "text",
"required": true
},
{
"name": "description",
"label": "Description",
"placeholder": "e.g. How many iterations should the algorithm perform.",
"type": "text",
"required": true
},
{
"name": "type",
"label": "Parameter Type",
"help": "The field type (text, number, boolean, select). This influences how the parameter is displayed for the consumer before the asset is used.",
"type": "select",
"options": ["number", "text", "boolean", "select"],
"required": true
},
{
"name": "options",
"label": "Select Options",
"help": "For select types, a list of options.",
"type": "creatableSelect",
"required": true
},
{
"name": "required",
"label": "Required",
"options": ["optional", "required"],
"type": "select",
"required": true
},
{
"name": "default",
"label": "Default Value",
"placeholder": "e.g. 6",
"required": true
}
]
}
]
}
}

View File

@ -74,6 +74,14 @@
"help": "Provide the entrypoint for your algorithm.",
"required": true
},
{
"name": "usesConsumerParameters",
"label": "Algorithm custom parameters",
"help": "Algorithm custom parameters are used to define required consumer input before running the algorithm in a Compute-to-Data environment.",
"type": "checkbox",
"options": ["This asset uses algorithm custom parameters"],
"required": false
},
{
"name": "termsAndConditions",
"label": "Terms & Conditions",
@ -255,6 +263,14 @@
"options": ["Forever", "1 day", "1 week", "1 month", "1 year"],
"sortOptions": false,
"required": true
},
{
"name": "usesConsumerParameters",
"label": "User defined parameters",
"help": "User defined parameters are used to filter or query the published asset.",
"type": "checkbox",
"options": ["This asset uses user defined parameters"],
"required": false
}
]
},

View File

@ -6,5 +6,7 @@ declare global {
interface AssetExtended extends Asset {
accessDetails?: AccessDetails
views?: number
metadata: MetadataExtended
services: ServiceExtended[]
}
}

View File

@ -2,10 +2,14 @@ import {
ComputeEditForm,
MetadataEditForm
} from '@components/Asset/Edit/_types'
import { FormPublishData } from '@components/Publish/_types'
import {
FormConsumerParameter,
FormPublishData
} from '@components/Publish/_types'
import {
Arweave,
Asset,
ConsumerParameter,
DDO,
FileInfo,
GraphqlQuery,
@ -188,3 +192,30 @@ export function previewDebugPatch(
return buildValuesPreview
}
export function parseConsumerParameters(
consumerParameters: ConsumerParameter[]
): FormConsumerParameter[] {
if (!consumerParameters?.length) return []
return consumerParameters.map((param) => ({
...param,
required: param.required ? 'required' : 'optional',
options:
param.type === 'select'
? JSON.parse(param.options)?.map((option) => {
const key = Object.keys(option)[0]
return {
key,
value: option[key]
}
})
: [],
default:
param.type === 'boolean'
? param.default === 'true'
: param.type === 'number'
? Number(param.default)
: param.default
}))
}

View File

@ -17,3 +17,27 @@ export function sortAssets(items: Asset[], sorted: string[]) {
})
return items
}
export const isPlainObject = (object: any) => {
return object !== null && typeof object === 'object' && !Array.isArray(object)
}
export const getObjectPropertyByPath = (object: any, path = '') => {
if (!isPlainObject(object)) return undefined
path = path.replace(/\[(\w+)\]/g, '.$1') // convert indexes to properties
path = path.replace(/^\./, '') // strip a leading dot
const pathArray = path.split('.')
for (let i = 0, n = pathArray.length; i < n; ++i) {
const key = pathArray[i]
try {
if (key in object) {
object = object[key]
} else {
return undefined
}
} catch {
return undefined
}
}
return object
}

View File

@ -13,11 +13,12 @@ import {
ProviderInstance,
UrlFile,
AbiItem,
UserCustomParameters,
getErrorMessage
} from '@oceanprotocol/lib'
// if customProviderUrl is set, we need to call provider using this custom endpoint
import { customProviderUrl } from '../../app.config'
import { QueryHeader } from '@shared/FormInput/InputElement/Headers'
import { KeyValuePair } from '@shared/FormInput/InputElement/KeyValueInput'
import { Signer } from 'ethers'
import { getValidUntilTime } from './compute'
import { toast } from 'react-toastify'
@ -110,7 +111,7 @@ export async function getFileInfo(
providerUrl: string,
storageType: string,
query?: string,
headers?: QueryHeader[],
headers?: KeyValuePair[],
abi?: string,
chainId?: number,
method?: string
@ -226,7 +227,8 @@ export async function downloadFile(
signer: Signer,
asset: AssetExtended,
accountId: string,
validOrderTx?: string
validOrderTx?: string,
userCustomParameters?: UserCustomParameters
) {
let downloadUrl
try {
@ -236,7 +238,8 @@ export async function downloadFile(
0,
validOrderTx || asset.accessDetails.validOrderTx,
customProviderUrl || asset.services[0].serviceEndpoint,
signer
signer,
userCustomParameters
)
} catch (error) {
const message = getErrorMessage(JSON.parse(error.message))

View File

@ -0,0 +1,40 @@
import React, { ReactElement } from 'react'
import Input, { InputProps } from '../..'
import { Field, useField } from 'formik'
import { FormConsumerParameter } from '@components/Publish/_types'
export default function DefaultInput({
index,
inputName,
...props
}: InputProps & {
index: number
inputName: string
}): ReactElement {
const [field] = useField<FormConsumerParameter[]>(inputName)
const fieldType = field.value[index]?.type
const fieldOptions = field.value[index]?.options
const getStringOptions = (
options: { key: string; value: string }[]
): string[] => {
if (!options?.length) return []
return options.map((option) => option.key)
}
return (
<Field
{...props}
component={Input}
type={fieldType === 'boolean' ? 'select' : fieldType}
options={
fieldType === 'boolean'
? ['true', 'false']
: fieldType === 'select'
? getStringOptions(fieldOptions)
: fieldOptions
}
/>
)
}

View File

@ -0,0 +1,5 @@
.actions {
display: flex;
justify-content: center;
gap: var(--spacer);
}

View File

@ -0,0 +1,84 @@
import React, { ReactElement } from 'react'
import Button from '@shared/atoms/Button'
import styles from './FormActions.module.css'
import { useField, useFormikContext } from 'formik'
import {
FormConsumerParameter,
FormPublishData
} from '@components/Publish/_types'
import { getObjectPropertyByPath } from '@utils/index'
import { defaultConsumerParam } from '.'
import { toast } from 'react-toastify'
export default function FormActions({
fieldName,
index,
onParameterAdded,
onParameterDeleted
}: {
fieldName: string
index: number
onParameterAdded?: (index: number) => void
onParameterDeleted?: (previousIndex: number) => void
}): ReactElement {
const { errors, setFieldTouched, validateField } =
useFormikContext<FormPublishData>()
const [field, meta, helpers] = useField<FormConsumerParameter[]>(fieldName)
const setParamPropsTouched = (index: number, touched = true) => {
Object.keys(defaultConsumerParam).forEach((param) => {
setFieldTouched(`${field.name}[${index}].${param}`, touched)
})
}
const addParameter = (index: number) => {
// validate parameter before allowing the creation of a new one
validateField(field.name)
setParamPropsTouched(index)
// Check errors on current tab before creating a new param
if (getObjectPropertyByPath(errors, `${field.name}[${index}]`)) {
toast.error(
'Cannot add new parameter. Current parameter definition contains errors.'
)
return
}
helpers.setValue([...field.value, { ...defaultConsumerParam }])
onParameterAdded && onParameterAdded(field.value.length)
}
const deleteParameter = (index: number) => {
helpers.setValue(field.value.filter((p, i) => i !== index))
const previousIndex = Math.max(0, index - 1)
onParameterDeleted && onParameterDeleted(previousIndex)
}
return (
<div className={styles.actions}>
<Button
style="ghost"
size="small"
disabled={field.value.length === 1}
onClick={(e) => {
e.preventDefault()
deleteParameter(index)
}}
>
Delete parameter
</Button>
<Button
style="primary"
size="small"
onClick={(e) => {
e.preventDefault()
addParameter(index)
}}
>
Add new parameter
</Button>
</div>
)
}

View File

@ -0,0 +1,16 @@
.container {
position: relative;
}
.container.hasError * {
border-color: var(--brand-alert-red);
}
.hasError label {
color: var(--brand-alert-red);
}
.error {
composes: error from '../../index.module.css';
top: 100%;
}

View File

@ -0,0 +1,36 @@
import React, { ReactElement } from 'react'
import { FormPublishData } from '@components/Publish/_types'
import classNames from 'classnames/bind'
import { ErrorMessage, Field, useFormikContext } from 'formik'
import { getObjectPropertyByPath } from '@utils/index'
import InputKeyValue, { KeyValueInputProps } from '../KeyValueInput'
import styles from './OptionsInput.module.css'
const cx = classNames.bind(styles)
export default function OptionsInput(props: KeyValueInputProps): ReactElement {
const { errors, touched } = useFormikContext<FormPublishData>()
const hasError = (): boolean =>
[errors, touched].every(
(object) => !!getObjectPropertyByPath(object, props.name)
)
return (
<div className={cx({ container: true, hasError: hasError() })}>
<Field
{...props}
component={InputKeyValue}
uniqueKeys
keyPlaceholder="value"
valuePlaceholder="label"
/>
{hasError() && (
<div className={styles.error}>
<ErrorMessage name={props.name} />
</div>
)}
</div>
)
}

View File

@ -0,0 +1,53 @@
import React, { ReactElement } from 'react'
import Input, { InputProps } from '../..'
import { Field, useField, useFormikContext } from 'formik'
import {
FormConsumerParameter,
FormPublishData
} from '@components/Publish/_types'
import { defaultConsumerParam } from '.'
export default function TypeInput({
index,
inputName,
...props
}: InputProps & {
index: number
inputName: string
}): ReactElement {
const { setFieldTouched } = useFormikContext<FormPublishData>()
const [field, meta, helpers] = useField<FormConsumerParameter[]>(inputName)
const resetDefaultValue = (
parameterName: string,
parameterType: FormConsumerParameter['type'],
index: number
) => {
if (parameterName !== 'type') return
if (field.value[index].type === parameterType) return
setFieldTouched(`${field.name}[${index}].default`, false)
helpers.setValue(
field.value.map((currentParam, i) => {
if (i !== index) return currentParam
return {
...defaultConsumerParam,
...currentParam,
default: defaultConsumerParam.default
}
})
)
}
return (
<Field
{...props}
component={Input}
onChange={(e) => {
resetDefaultValue(props.name, e.target.value, index)
field.onChange(e)
}}
/>
)
}

View File

@ -0,0 +1,62 @@
import {
FormConsumerParameter,
FormPublishData,
FormPublishService
} from '@components/Publish/_types'
import * as Yup from 'yup'
import { SchemaLike } from 'yup/lib/types'
import { paramTypes } from '.'
interface TestContextExtended extends Yup.TestContext {
from: {
value: FormPublishData['metadata'] | FormPublishService
}[]
}
export const validationConsumerParameters: {
[key in keyof FormConsumerParameter]: SchemaLike
} = {
name: Yup.string()
.test('unique', 'Parameter names must be unique', (name, context) => {
const [parentFormObj, nextParentFormObj] = (
context as TestContextExtended
).from
if (
!nextParentFormObj?.value?.consumerParameters ||
nextParentFormObj.value.consumerParameters.length === 1
)
return true
const { consumerParameters } = nextParentFormObj.value
const occasions = consumerParameters.filter(
(params) => params.name === name
)
return occasions.length === 1
})
.min(4, (param) => `Name must be at least ${param.min} characters`)
.max(50, (param) => `Name must have maximum ${param.max} characters`)
.required('Required'),
type: Yup.string().oneOf(paramTypes).required('Required'),
description: Yup.string()
.min(10, (param) => `Description must be at least ${param.min} characters`)
.max(
500,
(param) => `Description must have maximum ${param.max} characters`
)
.required('Required'),
label: Yup.string()
.min(4, (param) => `Label must be at least ${param.min} characters`)
.max(50, (param) => `Label must have maximum ${param.max} characters`)
.required('Required'),
required: Yup.string().oneOf(['optional', 'required']).required('Required'),
default: Yup.mixed().required('Required'),
options: Yup.array().when('type', {
is: 'select',
then: Yup.array()
.of(Yup.object())
.min(2, 'At least two options are required')
.required('Required'),
otherwise: Yup.array()
})
}

View File

@ -0,0 +1,4 @@
.container {
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
}

View File

@ -0,0 +1,110 @@
import React, { ReactElement, useEffect, useState } from 'react'
import { Field, useField } from 'formik'
import Input, { InputProps } from '../..'
import { FormConsumerParameter } from '@components/Publish/_types'
import Tabs from '@shared/atoms/Tabs'
import FormActions from './FormActions'
import DefaultInput from './DefaultInput'
import OptionsInput from './OptionsInput'
import styles from './index.module.css'
import TypeInput from './TypeInput'
export const defaultConsumerParam: FormConsumerParameter = {
name: '',
label: '',
description: '',
type: 'text',
options: undefined,
default: '',
required: 'optional'
}
export const paramTypes: FormConsumerParameter['type'][] = [
'number',
'text',
'boolean',
'select'
]
export function ConsumerParameters(props: InputProps): ReactElement {
const [field, meta, helpers] = useField<FormConsumerParameter[]>(props.name)
const [tabIndex, setTabIndex] = useState(0)
useEffect(() => {
if (field.value.length === 0)
helpers.setValue([{ ...defaultConsumerParam }])
}, [])
return (
<div className={styles.container}>
<Tabs
selectedIndex={tabIndex}
onIndexSelected={setTabIndex}
items={field.value.map((param, index) => {
return {
title:
param?.name?.length > 15
? `${param?.name?.slice(0, 15)}...`
: param?.name || 'New parameter',
content: (
<div>
{props.fields?.map((subField: InputProps) => {
if (subField.name === 'options') {
return field.value[index]?.type === 'select' ? (
<OptionsInput
key={`${field.name}[${index}].${props.name}`}
{...subField}
name={`${field.name}[${index}].${subField.name}`}
value={field.value[index][subField.name]}
/>
) : null
}
if (subField.name === 'default') {
return (
<DefaultInput
key={`${field.name}[${index}].${subField.name}`}
{...subField}
name={`${field.name}[${index}].${subField.name}`}
index={index}
inputName={props.name}
/>
)
}
if (subField.name === 'type') {
return (
<TypeInput
key={`${field.name}[${index}].${subField.name}`}
{...subField}
name={`${field.name}[${index}].${subField.name}`}
index={index}
inputName={props.name}
/>
)
}
return (
<Field
key={`${field.name}[${index}].${subField.name}`}
{...subField}
name={`${field.name}[${index}].${subField.name}`}
component={Input}
/>
)
})}
<FormActions
fieldName={props.name}
index={index}
onParameterAdded={setTabIndex}
onParameterDeleted={setTabIndex}
/>
</div>
)
}
})}
/>
</div>
)
}

View File

@ -8,7 +8,7 @@ import { LoggerInstance, FileInfo } from '@oceanprotocol/lib'
import { useAsset } from '@context/Asset'
import styles from './index.module.css'
import { useNetwork } from 'wagmi'
import InputHeaders from '../Headers'
import InputKeyValue from '../KeyValueInput'
import Button from '@shared/atoms/Button'
import Loader from '@shared/atoms/Loader'
import { checkJson } from '@utils/codemirror'
@ -161,7 +161,9 @@ export default function FilesInput(props: InputProps): ReactElement {
<Field
key={i}
component={
innerField.type === 'headers' ? InputHeaders : Input
innerField.type === 'headers'
? InputKeyValue
: Input
}
{...innerField}
name={`${field.name}[0].${innerField.value}`}

View File

@ -1,13 +0,0 @@
.headersContainer {
display: flex;
justify-content: space-between;
gap: 0 calc(var(--spacer) / 4);
margin-bottom: var(--spacer);
}
.headersAddedContainer {
display: flex;
justify-content: space-between;
gap: 0 calc(var(--spacer) / 4);
margin-bottom: var(--spacer);
}

View File

@ -0,0 +1,35 @@
.pairsContainer {
display: flex;
justify-content: space-between;
gap: 0 calc(var(--spacer) / 4);
margin-bottom: var(--spacer);
position: relative;
}
.pairsAddedContainer {
display: flex;
justify-content: space-between;
gap: 0 calc(var(--spacer) / 4);
margin-bottom: var(--spacer);
}
.hasError label {
color: var(--brand-alert-red);
}
.hasError .pairsContainer .keyInput,
.hasError .pairsContainer .keyInput:focus,
.hasError .pairsContainer [class*='prefix'],
.hasError .pairsContainer [class*='postfix'] {
color: var(--brand-alert-red);
border-color: var(--brand-alert-red);
}
.required {
composes: required from '../../index.module.css';
}
.error {
composes: error from '../../index.module.css';
top: 100%;
}

View File

@ -1,28 +1,60 @@
import React, { ChangeEvent, ReactElement, useEffect, useState } from 'react'
import InputElement from '../../InputElement'
import React, {
ChangeEvent,
ReactElement,
useCallback,
useEffect,
useState
} from 'react'
import InputElement from '..'
import Label from '../../Label'
import styles from './index.module.css'
import Tooltip from '@shared/atoms/Tooltip'
import Markdown from '@shared/Markdown'
import Button from '@shared/atoms/Button'
import { InputProps } from '@shared/FormInput'
import classNames from 'classnames/bind'
export interface QueryHeader {
const cx = classNames.bind(styles)
export interface KeyValuePair {
key: string
value: string
}
export default function InputHeaders(props: InputProps): ReactElement {
export interface KeyValueInputProps extends Omit<InputProps, 'value'> {
value: KeyValuePair[]
uniqueKeys?: boolean
keyPlaceholder?: string
valuePlaceholder?: string
}
export default function InputKeyValue({
uniqueKeys = false,
value,
keyPlaceholder = 'key',
valuePlaceholder = 'value',
...props
}: KeyValueInputProps): ReactElement {
const { label, help, prominentHelp, form, field } = props
const [currentKey, setCurrentKey] = useState('')
const [currentValue, setCurrentValue] = useState('')
const [disabledButton, setDisabledButton] = useState(true)
const [hasOnlyUniqueKeys, setHasOnlyUniqueKeys] = useState(true)
const [headers, setHeaders] = useState([] as QueryHeader[])
const [pairs, setPairs] = useState(value || [])
const addHeader = () => {
setHeaders((prev) => [
const currentKeyExists = useCallback(() => {
return pairs.some((pair) => pair.key === currentKey)
}, [currentKey, pairs])
const addPair = () => {
if (currentKeyExists()) {
setHasOnlyUniqueKeys(false)
if (uniqueKeys) return
}
setPairs((prev) => [
...prev,
{
key: currentKey,
@ -33,9 +65,9 @@ export default function InputHeaders(props: InputProps): ReactElement {
setCurrentValue('')
}
const removeHeader = (i: number) => {
const newHeaders = headers.filter((header, index) => index !== i)
setHeaders(newHeaders)
const removePair = (index: number) => {
const newPairs = pairs.filter((pair, pairIndex) => pairIndex !== index)
setPairs(newPairs)
setCurrentKey('')
setCurrentValue('')
}
@ -50,15 +82,22 @@ export default function InputHeaders(props: InputProps): ReactElement {
}
useEffect(() => {
form.setFieldValue(`${field.name}`, headers)
}, [headers])
form.setFieldValue(`${field.name}`, pairs)
}, [pairs])
useEffect(() => {
setDisabledButton(!currentKey || !currentValue)
}, [currentKey, currentValue])
setDisabledButton(
!currentKey || !currentValue || (uniqueKeys && currentKeyExists())
)
setHasOnlyUniqueKeys(!currentKeyExists())
}, [currentKey, currentValue, uniqueKeys, currentKeyExists])
return (
<div>
<div
className={cx({
hasError: uniqueKeys && !hasOnlyUniqueKeys
})}
>
<Label htmlFor={props.name}>
{label}
{props.required && (
@ -71,18 +110,19 @@ export default function InputHeaders(props: InputProps): ReactElement {
)}
</Label>
<div className={styles.headersContainer}>
<div className={styles.pairsContainer}>
<InputElement
className={styles.keyInput}
name={`${field.name}.key`}
placeholder={'key'}
placeholder={keyPlaceholder}
value={`${currentKey}`}
onChange={handleChange}
/>
<InputElement
className={`${styles.input}`}
className={styles.input}
name={`${field.name}.value`}
placeholder={'value'}
placeholder={valuePlaceholder}
value={`${currentValue}`}
onChange={handleChange}
/>
@ -92,26 +132,32 @@ export default function InputHeaders(props: InputProps): ReactElement {
size="small"
onClick={(e: React.SyntheticEvent) => {
e.preventDefault()
addHeader()
addPair()
}}
disabled={disabledButton}
>
add
</Button>
{uniqueKeys && !hasOnlyUniqueKeys && (
<p
className={styles.error}
>{`The ${keyPlaceholder} field must be unique`}</p>
)}
</div>
{headers.length > 0 &&
headers.map((header, i) => {
{pairs.length > 0 &&
pairs.map((header, i) => {
return (
<div className={styles.headersAddedContainer} key={`header_${i}`}>
<div className={styles.pairsAddedContainer} key={`pair_${i}`}>
<InputElement
name={`header[${i}].key`}
name={`pair[${i}].key`}
value={`${header.key}`}
disabled
/>
<InputElement
name={`header[${i}].key`}
name={`pair[${i}].value`}
value={`${header.value}`}
disabled
/>
@ -121,7 +167,7 @@ export default function InputHeaders(props: InputProps): ReactElement {
size="small"
onClick={(e: React.SyntheticEvent) => {
e.preventDefault()
removeHeader(i)
removePair(i)
}}
disabled={false}
>

View File

@ -16,6 +16,7 @@ import TabsFile from '@shared/atoms/TabsFile'
import useDarkMode from '@oceanprotocol/use-dark-mode'
import appConfig from '../../../../../app.config'
import { extensions, oceanTheme } from '@utils/codemirror'
import { ConsumerParameters } from './ConsumerParameters'
const cx = classNames.bind(styles)
@ -130,6 +131,9 @@ export default function InputElement({
/>
)
case 'consumerParameters':
return <ConsumerParameters {...field} form={form} {...props} />
case 'textarea':
return <textarea id={props.name} className={styles.textarea} {...props} />

View File

@ -10,7 +10,7 @@ import React, {
import InputElement from './InputElement'
import Label from './Label'
import styles from './index.module.css'
import { ErrorMessage, FieldInputProps } from 'formik'
import { ErrorMessage, FieldInputProps, useField } from 'formik'
import classNames from 'classnames/bind'
import Disclaimer from './Disclaimer'
import Tooltip from '@shared/atoms/Tooltip'
@ -18,6 +18,7 @@ import Markdown from '@shared/Markdown'
import FormHelp from './Help'
import { AssetSelectionAsset } from '@shared/FormInput/InputElement/AssetSelection'
import { BoxSelectionOption } from '@shared/FormInput/InputElement/BoxSelection'
import { getObjectPropertyByPath } from '@utils/index'
const cx = classNames.bind(styles)
@ -71,21 +72,17 @@ export interface InputProps {
disclaimerValues?: string[]
}
function checkError(
form: any,
parsedFieldName: string[],
field: FieldInputProps<any>
) {
if (
(form?.touched?.[parsedFieldName[0]]?.[parsedFieldName[1]] &&
form?.errors?.[parsedFieldName[0]]?.[parsedFieldName[1]]) ||
(form?.touched[field?.name] &&
form?.errors[field?.name] &&
field.name !== 'files' &&
field.name !== 'links')
) {
return true
}
function checkError(form: any, field: FieldInputProps<any>) {
const touched = getObjectPropertyByPath(form?.touched, field?.name)
const errors = getObjectPropertyByPath(form?.errors, field?.name)
return (
touched &&
errors &&
!field.name.endsWith('.files') &&
!field.name.endsWith('.links') &&
!field.name.endsWith('consumerParameters')
)
}
export default function Input(props: Partial<InputProps>): ReactElement {
@ -102,13 +99,10 @@ export default function Input(props: Partial<InputProps>): ReactElement {
} = props
const isFormikField = typeof field !== 'undefined'
const isNestedField = field?.name?.includes('.')
// TODO: this feels hacky as it assumes nested `values` store. But we can't use the
// `useField()` hook in here to get `meta.error` so we have to match against form?.errors?
// handling flat and nested data at same time.
const parsedFieldName =
isFormikField && (isNestedField ? field?.name.split('.') : [field?.name])
const hasFormikError = checkError(form, parsedFieldName, field)
const hasFormikError = checkError(form, field)
const styleClasses = cx({
field: true,
@ -123,7 +117,7 @@ export default function Input(props: Partial<InputProps>): ReactElement {
if (disclaimer && disclaimerValues) {
setDisclaimerVisible(
disclaimerValues.includes(
props.form?.values[parsedFieldName[0]]?.[parsedFieldName[1]]
getObjectPropertyByPath(props.form?.values, field?.name)
)
)
}

View File

@ -1,8 +1,7 @@
import React, { ReactElement, ReactNode } from 'react'
import { Tab, Tabs as ReactTabs, TabList, TabPanel } from 'react-tabs'
import styles from './index.module.css'
import InputRadio from '@shared/FormInput/InputElement/Radio'
import styles from './index.module.css'
export interface TabsItem {
title: string
content: ReactNode
@ -15,6 +14,8 @@ export interface TabsProps {
handleTabChange?: (tabName: string) => void
defaultIndex?: number
showRadio?: boolean
selectedIndex?: number
onIndexSelected?: (index: number) => void
}
export default function Tabs({
@ -22,10 +23,17 @@ export default function Tabs({
className,
handleTabChange,
defaultIndex,
showRadio
showRadio,
selectedIndex,
onIndexSelected
}: TabsProps): ReactElement {
return (
<ReactTabs className={`${className || ''}`} defaultIndex={defaultIndex}>
<ReactTabs
className={`${className || ''}`}
selectedIndex={selectedIndex}
onSelect={onIndexSelected}
defaultIndex={selectedIndex ? undefined : defaultIndex}
>
<div className={styles.tabListContainer}>
<TabList className={styles.tabList}>
{items.map((item, index) => (

View File

@ -10,14 +10,14 @@ import { useAsset } from '@context/Asset'
import content from '../../../../../content/pages/startComputeDataset.json'
import { Asset, ZERO_ADDRESS } from '@oceanprotocol/lib'
import { getAccessDetails } from '@utils/accessDetailsAndPricing'
import { useMarketMetadata } from '@context/MarketMetadata'
import Alert from '@shared/atoms/Alert'
import { getTokenBalanceFromSymbol } from '@utils/wallet'
import { MAX_DECIMALS } from '@utils/constants'
import Decimal from 'decimal.js'
import { useAccount } from 'wagmi'
import useBalance from '@hooks/useBalance'
import useNetworkMetadata from '@hooks/useNetworkMetadata'
import ConsumerParameters from '../ConsumerParameters'
import { FormConsumerParameter } from '@components/Publish/_types'
export default function FormStartCompute({
algorithms,
@ -78,12 +78,18 @@ export default function FormStartCompute({
validUntil?: string
retry: boolean
}): ReactElement {
const { siteContent } = useMarketMetadata()
const { address: accountId, isConnected } = useAccount()
const { balance } = useBalance()
const { isSupportedOceanNetwork } = useNetworkMetadata()
const { isValid, values }: FormikContextType<{ algorithm: string }> =
useFormikContext()
const {
isValid,
values
}: FormikContextType<{
algorithm: string
dataServiceParams: FormConsumerParameter[]
algoServiceParams: FormConsumerParameter[]
algoParams: FormConsumerParameter[]
}> = useFormikContext()
const { asset, isAssetNetwork } = useAsset()
const [datasetOrderPrice, setDatasetOrderPrice] = useState(
@ -249,7 +255,12 @@ export default function FormStartCompute({
/>
)
})}
{asset && selectedAlgorithmAsset && (
<ConsumerParameters
asset={asset}
selectedAlgorithmAsset={selectedAlgorithmAsset}
/>
)}
<PriceOutput
hasPreviousOrder={hasPreviousOrder}
assetTimeout={assetTimeout}

View File

@ -1,7 +1,6 @@
.priceComponent {
margin-left: -2rem;
margin-right: -2rem;
margin-top: -1rem;
margin-bottom: calc(var(--spacer) / 1.5);
padding-left: calc(var(--spacer) / 2);
padding-right: calc(var(--spacer) / 2);

View File

@ -1,13 +1,45 @@
import { ConsumerParameter, UserCustomParameters } from '@oceanprotocol/lib'
import * as Yup from 'yup'
import { getDefaultValues } from '../ConsumerParameters/FormConsumerParameters'
import { getUserCustomParameterValidationSchema } from '../ConsumerParameters/_validation'
export const validationSchema: Yup.SchemaOf<{
export function getComputeValidationSchema(
dataServiceParams: ConsumerParameter[],
algoServiceParams: ConsumerParameter[],
algoParams: ConsumerParameter[]
): Yup.SchemaOf<{
algorithm: string
}> = Yup.object().shape({
algorithm: Yup.string().required('Required')
})
dataServiceParams: any
algoServiceParams: any
algoParams: any
}> {
return Yup.object().shape({
algorithm: Yup.string().required('Required'),
dataServiceParams:
getUserCustomParameterValidationSchema(dataServiceParams),
algoServiceParams:
getUserCustomParameterValidationSchema(algoServiceParams),
algoParams: getUserCustomParameterValidationSchema(algoParams)
})
}
export function getInitialValues(): { algorithm: string } {
export function getInitialValues(
asset?: AssetExtended,
selectedAlgorithmAsset?: AssetExtended
): {
algorithm: string
dataServiceParams?: UserCustomParameters
algoServiceParams?: UserCustomParameters
algoParams?: UserCustomParameters
} {
return {
algorithm: undefined
algorithm: selectedAlgorithmAsset?.id,
dataServiceParams: getDefaultValues(asset?.services[0].consumerParameters),
algoServiceParams: getDefaultValues(
selectedAlgorithmAsset?.services[0].consumerParameters
),
algoParams: getDefaultValues(
selectedAlgorithmAsset?.metadata?.algorithm.consumerParameters
)
}
}

View File

@ -13,8 +13,8 @@ import {
ComputeOutput,
ProviderComputeInitializeResults,
unitsToAmount,
minAbi,
ProviderFees,
UserCustomParameters,
getErrorMessage
} from '@oceanprotocol/lib'
import { toast } from 'react-toastify'
@ -22,7 +22,7 @@ import Price from '@shared/Price'
import FileIcon from '@shared/FileIcon'
import Alert from '@shared/atoms/Alert'
import { Formik } from 'formik'
import { getInitialValues, validationSchema } from './_constants'
import { getComputeValidationSchema, getInitialValues } from './_constants'
import FormStartComputeDataset from './FormComputeDataset'
import styles from './index.module.css'
import SuccessConfetti from '@shared/SuccessConfetti'
@ -50,6 +50,7 @@ import { useAccount, useSigner } from 'wagmi'
import { getDummySigner } from '@utils/wallet'
import useNetworkMetadata from '@hooks/useNetworkMetadata'
import { useAsset } from '@context/Asset'
import { parseConsumerParameterValues } from '../ConsumerParameters'
const refreshInterval = 10000 // 10 sec.
@ -339,7 +340,11 @@ export default function Compute({
toast.error(errorMsg)
}, [error])
async function startJob(): Promise<void> {
async function startJob(userCustomParameters: {
dataServiceParams?: UserCustomParameters
algoServiceParams?: UserCustomParameters
algoParams?: UserCustomParameters
}): Promise<void> {
try {
setIsOrdering(true)
setIsOrdered(false)
@ -347,7 +352,9 @@ export default function Compute({
const computeService = getServiceByName(asset, 'compute')
const computeAlgorithm: ComputeAlgorithm = {
documentId: selectedAlgorithmAsset.id,
serviceId: selectedAlgorithmAsset.services[0].id
serviceId: selectedAlgorithmAsset.services[0].id,
algocustomdata: userCustomParameters?.algoParams,
userdata: userCustomParameters?.algoServiceParams
}
const allowed = await isOrderable(
@ -406,7 +413,8 @@ export default function Compute({
const computeAsset: ComputeAsset = {
documentId: asset.id,
serviceId: asset.services[0].id,
transferTxId: datasetOrderTx
transferTxId: datasetOrderTx,
userdata: userCustomParameters?.dataServiceParams
}
computeAlgorithm.transferTxId = algorithmOrderTx
const output: ComputeOutput = {
@ -440,6 +448,32 @@ export default function Compute({
}
}
const onSubmit = async (values: {
algorithm: string
dataServiceParams?: UserCustomParameters
algoServiceParams?: UserCustomParameters
algoParams?: UserCustomParameters
}) => {
if (!values.algorithm) return
const userCustomParameters = {
dataServiceParams: parseConsumerParameterValues(
values?.dataServiceParams,
asset.services[0].consumerParameters
),
algoServiceParams: parseConsumerParameterValues(
values?.algoServiceParams,
selectedAlgorithmAsset?.services[0].consumerParameters
),
algoParams: parseConsumerParameterValues(
values?.algoParams,
selectedAlgorithmAsset?.metadata?.algorithm?.consumerParameters
)
}
await startJob(userCustomParameters)
}
return (
<>
<div
@ -480,13 +514,15 @@ export default function Compute({
</>
) : (
<Formik
initialValues={getInitialValues()}
initialValues={getInitialValues(asset, selectedAlgorithmAsset)}
validateOnMount
validationSchema={validationSchema}
onSubmit={async (values) => {
if (!values.algorithm) return
await startJob()
}}
validationSchema={getComputeValidationSchema(
asset.services[0].consumerParameters,
selectedAlgorithmAsset?.services[0].consumerParameters,
selectedAlgorithmAsset?.metadata?.algorithm?.consumerParameters
)}
enableReinitialize
onSubmit={onSubmit}
>
<FormStartComputeDataset
algorithms={algorithmList}

View File

@ -0,0 +1,39 @@
.container > label {
margin-bottom: calc(var(--spacer) / 2);
text-align: center;
}
.parametersContainer {
composes: selection from '@shared/FormInput/InputElement/AssetSelection/index.module.css';
padding: calc(var(--spacer) / 3) calc(var(--spacer) / 2);
max-height: 50vh;
/* smooth overflow scrolling for pre-iOS 13 */
overflow: auto;
-webkit-overflow-scrolling: touch;
}
.parameter > div {
margin-bottom: 0;
padding-left: 1rem;
padding-right: 1rem;
}
.parameter {
position: relative;
margin-left: -1rem;
margin-right: -1rem;
margin-bottom: calc(var(--spacer) / 2);
padding-bottom: calc(var(--spacer) / 2);
border-bottom: 1px solid var(--border-color);
}
.parameter:last-child {
border: none;
}
.parameter > div > div {
position: absolute;
right: 1rem;
}

View File

@ -0,0 +1,78 @@
import React, { ReactElement } from 'react'
import Input from '@shared/FormInput'
import Label from '@shared/FormInput/Label'
import { Field, useField } from 'formik'
import styles from './FormConsumerParameters.module.css'
import { ConsumerParameter, UserCustomParameters } from '@oceanprotocol/lib'
export function getDefaultValues(
parameters: ConsumerParameter[]
): UserCustomParameters {
const defaults = {}
parameters?.forEach((param) => {
Object.assign(defaults, {
[param.name]:
param.type === 'number'
? Number(param.default)
: param.type === 'boolean'
? param.default.toString()
: param.default
})
})
return defaults
}
export default function FormConsumerParameters({
name,
parameters
}: {
name: string
parameters: ConsumerParameter[]
}): ReactElement {
const [field] = useField<UserCustomParameters[]>(name)
const getParameterOptions = (parameter: ConsumerParameter): string[] => {
if (!parameter.options && parameter.type !== 'boolean') return []
const updatedOptions =
parameter.type === 'boolean'
? ['true', 'false']
: parameter.type === 'select'
? JSON.parse(parameter.options)?.map((option) => Object.keys(option)[0])
: []
// add empty option, if parameter is optional
if (!parameter.required) updatedOptions.unshift('')
return updatedOptions
}
return (
<div className={styles.container}>
<Label htmlFor="Input the consumer parameters">
Input the consumer parameters
</Label>
<div className={styles.parametersContainer}>
{parameters?.map((param) => {
const { default: paramDefault, ...rest } = param
return (
<div key={param.name} className={styles.parameter}>
<Field
{...rest}
component={Input}
help={param.description}
name={`${name}.${param.name}`}
options={getParameterOptions(param)}
size="small"
type={param.type === 'boolean' ? 'select' : param.type}
value={field.value[param.name]}
/>
</div>
)
})}
</div>
</div>
)
}

View File

@ -0,0 +1,28 @@
import { ConsumerParameter } from '@oceanprotocol/lib'
import * as Yup from 'yup'
import { SchemaLike } from 'yup/lib/types'
export function getUserCustomParameterValidationSchema(
parameters: ConsumerParameter[]
): SchemaLike {
const shape = {}
parameters?.forEach((parameter) => {
const schemaBase =
parameter.type === 'number'
? Yup.number()
: parameter.type === 'boolean'
? Yup.boolean()
: Yup.string()
Object.assign(shape, {
[parameter.name]: parameter.required
? schemaBase.required('required')
: schemaBase.nullable().transform((value) => value || null)
})
})
const schema = Yup.object(shape)
return schema
}

View File

@ -0,0 +1,8 @@
.container {
margin-top: -1rem;
margin-bottom: 1rem;
margin-left: -2rem;
margin-right: -2rem;
border-top: 1px solid var(--border-color);
border-bottom: 1px solid var(--border-color);
}

View File

@ -0,0 +1,97 @@
import React, { ReactElement, useCallback, useEffect, useState } from 'react'
import FormConsumerParameters from './FormConsumerParameters'
import styles from './index.module.css'
import Tabs, { TabsItem } from '@shared/atoms/Tabs'
import { ConsumerParameter, UserCustomParameters } from '@oceanprotocol/lib'
export function parseConsumerParameterValues(
formValues?: UserCustomParameters,
parameters?: ConsumerParameter[]
): UserCustomParameters {
if (!formValues) return
const parsedValues = {}
Object.entries(formValues)?.forEach((userCustomParameter) => {
const [userCustomParameterKey, userCustomParameterValue] =
userCustomParameter
const { type } = parameters.find(
(param) => param.name === userCustomParameterKey
)
Object.assign(parsedValues, {
[userCustomParameterKey]:
type === 'select' && userCustomParameterValue === ''
? undefined
: type === 'boolean'
? userCustomParameterValue === 'true'
: userCustomParameterValue
})
})
return parsedValues
}
export default function ConsumerParameters({
asset,
selectedAlgorithmAsset
}: {
asset: AssetExtended
selectedAlgorithmAsset?: AssetExtended
}): ReactElement {
const [tabs, setTabs] = useState<TabsItem[]>([])
const updateTabs = useCallback(() => {
const tabs = []
if (asset?.services[0]?.consumerParameters?.length > 0) {
tabs.push({
title: 'Data Service',
content: (
<FormConsumerParameters
name="dataServiceParams"
parameters={asset.services[0].consumerParameters}
/>
)
})
}
if (selectedAlgorithmAsset?.services[0]?.consumerParameters?.length > 0) {
tabs.push({
title: 'Algo Service',
content: (
<FormConsumerParameters
name="algoServiceParams"
parameters={selectedAlgorithmAsset.services[0].consumerParameters}
/>
)
})
}
if (
selectedAlgorithmAsset?.metadata?.algorithm?.consumerParameters?.length >
0
) {
tabs.push({
title: 'Algo Params',
content: (
<FormConsumerParameters
name="algoParams"
parameters={
selectedAlgorithmAsset.metadata?.algorithm.consumerParameters
}
/>
)
})
}
return tabs
}, [asset, selectedAlgorithmAsset])
useEffect(() => {
setTabs(updateTabs())
}, [updateTabs])
return (
<div className={styles.container}>
{tabs.length > 0 && <Tabs items={tabs} />}
</div>
)
}

View File

@ -0,0 +1,13 @@
import * as Yup from 'yup'
import { getUserCustomParameterValidationSchema } from '../ConsumerParameters/_validation'
import { ConsumerParameter } from '@oceanprotocol/lib'
export function getDownloadValidationSchema(
parameters: ConsumerParameter[]
): Yup.SchemaOf<{
dataServiceParams: any
}> {
return Yup.object().shape({
dataServiceParams: getUserCustomParameterValidationSchema(parameters)
})
}

View File

@ -8,13 +8,29 @@
display: flex;
width: auto;
padding: 0 calc(var(--spacer) / 2) 0 calc(var(--spacer) * 1.5);
margin-bottom: var(--spacer);
}
.filewrapper {
flex-shrink: 0;
}
.price {
align-self: center;
}
.feedback {
width: 100%;
margin-top: calc(var(--spacer));
}
.buttonBuy > div {
display: flex;
flex-direction: column;
justify-content: center;
}
.buttonBuy > div > button {
margin-left: auto;
margin-right: auto;
}

View File

@ -2,11 +2,16 @@ import React, { ReactElement, useEffect, useState } from 'react'
import FileIcon from '@shared/FileIcon'
import Price from '@shared/Price'
import { useAsset } from '@context/Asset'
import ButtonBuy from './ButtonBuy'
import ButtonBuy from '../ButtonBuy'
import { secondsToString } from '@utils/ddo'
import AlgorithmDatasetsListForCompute from './Compute/AlgorithmDatasetsListForCompute'
import styles from './Download.module.css'
import { FileInfo, LoggerInstance, ZERO_ADDRESS } from '@oceanprotocol/lib'
import AlgorithmDatasetsListForCompute from '../Compute/AlgorithmDatasetsListForCompute'
import styles from './index.module.css'
import {
FileInfo,
LoggerInstance,
UserCustomParameters,
ZERO_ADDRESS
} from '@oceanprotocol/lib'
import { order } from '@utils/order'
import { downloadFile } from '@utils/provider'
import { getOrderFeedback } from '@utils/feedback'
@ -18,6 +23,12 @@ import Alert from '@shared/atoms/Alert'
import Loader from '@shared/atoms/Loader'
import { useAccount, useSigner } from 'wagmi'
import useNetworkMetadata from '@hooks/useNetworkMetadata'
import ConsumerParameters, {
parseConsumerParameterValues
} from '../ConsumerParameters'
import { Form, Formik, useFormikContext } from 'formik'
import { getDownloadValidationSchema } from './_validation'
import { getDefaultValues } from '../ConsumerParameters/FormConsumerParameters'
export default function Download({
asset,
@ -142,7 +153,7 @@ export default function Download({
orderPriceAndFees
])
async function handleOrderOrDownload() {
async function handleOrderOrDownload(dataParams?: UserCustomParameters) {
setIsLoading(true)
try {
@ -154,7 +165,7 @@ export default function Download({
)[3]
)
await downloadFile(signer, asset, accountId, validOrderTx)
await downloadFile(signer, asset, accountId, validOrderTx, dataParams)
} else {
setStatusText(
getOrderFeedback(
@ -174,7 +185,7 @@ export default function Download({
throw new Error()
}
setIsOwned(true)
setValidOrderTx(tx?.transactionHash)
setValidOrderTx(tx.transactionHash)
}
} catch (error) {
LoggerInstance.error(error)
@ -187,16 +198,16 @@ export default function Download({
setIsLoading(false)
}
const PurchaseButton = () => (
const PurchaseButton = ({ isValid }: { isValid?: boolean }) => (
<ButtonBuy
action="download"
disabled={isDisabled}
disabled={isDisabled || !isValid}
hasPreviousOrder={isOwned}
hasDatatoken={hasDatatoken}
btSymbol={asset?.accessDetails?.baseToken?.symbol}
dtSymbol={asset?.datatokens[0]?.symbol}
dtBalance={dtBalance}
onClick={handleOrderOrDownload}
type="submit"
assetTimeout={secondsToString(asset?.services?.[0]?.timeout)}
assetType={asset?.metadata?.type}
stepText={statusText}
@ -212,6 +223,8 @@ export default function Download({
)
const AssetAction = ({ asset }: { asset: AssetExtended }) => {
const { isValid } = useFormikContext()
return (
<div>
{isOrderDisabled ? (
@ -230,18 +243,12 @@ export default function Download({
/>
) : (
<>
{isPriceLoading ? (
<Loader message="Calculating full price (including fees)" />
) : (
<Price
price={asset.stats?.price}
orderPriceAndFees={orderPriceAndFees}
conversion
size="large"
/>
{asset && <ConsumerParameters asset={asset} />}
{!isInPurgatory && (
<div className={styles.buttonBuy}>
<PurchaseButton isValid={isValid} />
</div>
)}
{!isInPurgatory && <PurchaseButton />}
</>
)}
</>
@ -251,20 +258,52 @@ export default function Download({
}
return (
<aside className={styles.consume}>
<div className={styles.info}>
<div className={styles.filewrapper}>
<FileIcon file={file} isLoading={fileIsLoading} small />
</div>
<AssetAction asset={asset} />
</div>
{asset?.metadata?.type === 'algorithm' && (
<AlgorithmDatasetsListForCompute
algorithmDid={asset.id}
asset={asset}
/>
<Formik
initialValues={{
dataServiceParams: getDefaultValues(
asset?.services[0].consumerParameters
)
}}
validationSchema={getDownloadValidationSchema(
asset?.services[0].consumerParameters
)}
</aside>
onSubmit={async (values) => {
const dataServiceParams = parseConsumerParameterValues(
values?.dataServiceParams,
asset.services[0].consumerParameters
)
await handleOrderOrDownload(dataServiceParams)
}}
>
<Form>
<aside className={styles.consume}>
<div className={styles.info}>
<div className={styles.filewrapper}>
<FileIcon file={file} isLoading={fileIsLoading} small />
</div>
{isPriceLoading ? (
<Loader message="Calculating full price (including fees)" />
) : (
<Price
className={styles.price}
price={asset.stats?.price}
orderPriceAndFees={orderPriceAndFees}
conversion
size="large"
/>
)}
</div>
<AssetAction asset={asset} />
{asset?.metadata?.type === 'algorithm' && (
<AlgorithmDatasetsListForCompute
algorithmDid={asset.id}
asset={asset}
/>
)}
</aside>
</Form>
</Formik>
)
}

View File

@ -4,7 +4,6 @@ import Download from './Download'
import { FileInfo, LoggerInstance, Datatoken } from '@oceanprotocol/lib'
import { compareAsBN } from '@utils/numbers'
import { useAsset } from '@context/Asset'
import Web3Feedback from '@shared/Web3Feedback'
import { getFileDidInfo, getFileInfo } from '@utils/provider'
import { getOceanConfig } from '@utils/ocean'
import { useCancelToken } from '@hooks/useCancelToken'

View File

@ -2,12 +2,12 @@ import React, { ReactElement, useState, useEffect } from 'react'
import { Formik } from 'formik'
import {
LoggerInstance,
Metadata,
FixedRateExchange,
Asset,
Service,
Datatoken,
Nft
Nft,
Metadata,
Service
} from '@oceanprotocol/lib'
import { validationSchema } from './_validation'
import { getInitialValues } from './_constants'
@ -28,6 +28,7 @@ import { sanitizeUrl } from '@utils/url'
import { getEncryptedFiles } from '@utils/provider'
import { assetStateToNumber } from '@utils/assetState'
import { useAccount, useProvider, useNetwork, useSigner } from 'wagmi'
import { transformConsumerParameters } from '@components/Publish/_utils'
export default function Edit({
asset
@ -104,6 +105,13 @@ export default function Edit({
tags: values.tags
}
if (asset.metadata.type === 'algorithm') {
updatedMetadata.algorithm.consumerParameters =
!values.usesConsumerParameters
? undefined
: transformConsumerParameters(values.consumerParameters)
}
asset?.accessDetails?.type === 'fixed' &&
values.price !== asset.accessDetails.price &&
(await updateFixedPrice(values.price))
@ -138,6 +146,11 @@ export default function Edit({
timeout: mapTimeoutStringToSeconds(values.timeout),
files: updatedFiles
}
if (values?.service?.consumerParameters) {
updatedService.consumerParameters = transformConsumerParameters(
values.service.consumerParameters
)
}
// TODO: remove version update at a later time
const updatedAsset: Asset = {
@ -189,7 +202,7 @@ export default function Edit({
enableReinitialize
initialValues={getInitialValues(
asset?.metadata,
asset?.services[0]?.timeout,
asset?.services[0],
asset?.accessDetails?.price || '0',
paymentCollector,
assetState

View File

@ -0,0 +1,4 @@
.serviceContainer {
border-top: 1px solid var(--border-color);
padding-top: var(--spacer);
}

View File

@ -7,6 +7,9 @@ import { FormPublishData } from '@components/Publish/_types'
import { getFileInfo } from '@utils/provider'
import { getFieldContent } from '@utils/form'
import { isGoogleUrl } from '@utils/url'
import styles from './FormEditMetadata.module.css'
import { MetadataEditForm } from './_types'
import consumerParametersContent from '../../../../content/publish/consumerParameters.json'
export function checkIfTimeoutInPredefinedValues(
timeout: string,
@ -130,6 +133,27 @@ export default function FormEditMetadata({
/>
<Field {...getFieldContent('tags', data)} component={Input} name="tags" />
{asset.metadata.type === 'algorithm' && (
<>
<Field
{...getFieldContent('usesConsumerParameters', data)}
component={Input}
name="usesConsumerParameters"
/>
{(values as unknown as MetadataEditForm).usesConsumerParameters && (
<Field
{...getFieldContent(
'consumerParameters',
consumerParametersContent.consumerParameters.fields
)}
component={Input}
name="consumerParameters"
/>
)}
</>
)}
<Field
{...getFieldContent('paymentCollector', data)}
component={Input}
@ -140,6 +164,26 @@ export default function FormEditMetadata({
component={Input}
name="assetState"
/>
<div className={styles.serviceContainer}>
<h4>Service</h4>
<Field
{...getFieldContent('usesServiceConsumerParameters', data)}
component={Input}
name="service.usesConsumerParameters"
/>
{(values as unknown as MetadataEditForm).service
.usesConsumerParameters && (
<Field
{...getFieldContent(
'consumerParameters',
consumerParametersContent.consumerParameters.fields
)}
component={Input}
name="service.consumerParameters"
/>
)}
</div>
<FormActions />
</Form>

View File

@ -1,10 +1,10 @@
import { Metadata, ServiceComputeOptions } from '@oceanprotocol/lib'
import { secondsToString } from '@utils/ddo'
import { Metadata, Service, ServiceComputeOptions } from '@oceanprotocol/lib'
import { parseConsumerParameters, secondsToString } from '@utils/ddo'
import { ComputeEditForm, MetadataEditForm } from './_types'
export function getInitialValues(
metadata: Metadata,
timeout: number,
service: Service,
price: string,
paymentCollector: string,
assetState: string
@ -15,11 +15,19 @@ export function getInitialValues(
price,
links: [{ url: '', type: 'url' }],
files: [{ url: '', type: 'ipfs' }],
timeout: secondsToString(timeout),
timeout: secondsToString(service?.timeout),
author: metadata?.author,
tags: metadata?.tags,
usesConsumerParameters: metadata?.algorithm?.consumerParameters?.length > 0,
consumerParameters: parseConsumerParameters(
metadata?.algorithm?.consumerParameters
),
paymentCollector,
assetState
assetState,
service: {
usesConsumerParameters: service?.consumerParameters?.length > 0,
consumerParameters: parseConsumerParameters(service?.consumerParameters)
}
}
}

View File

@ -1,3 +1,4 @@
import { FormConsumerParameter } from '@components/Publish/_types'
import { FileInfo } from '@oceanprotocol/lib'
export interface MetadataEditForm {
name: string
@ -9,7 +10,13 @@ export interface MetadataEditForm {
links?: FileInfo[]
author?: string
tags?: string[]
usesConsumerParameters?: boolean
consumerParameters?: FormConsumerParameter[]
assetState?: string
service?: {
usesConsumerParameters?: boolean
consumerParameters?: FormConsumerParameter[]
}
}
export interface ComputeEditForm {

View File

@ -1,7 +1,8 @@
import { FileInfo } from '@oceanprotocol/lib'
import * as Yup from 'yup'
import { isAddress } from 'ethers/lib/utils'
import { testLinks } from '../../../@utils/yup'
import { testLinks } from '@utils/yup'
import { validationConsumerParameters } from '@shared/FormInput/InputElement/ConsumerParameters/_validation'
export const validationSchema = Yup.object().shape({
name: Yup.string()
@ -37,6 +38,16 @@ export const validationSchema = Yup.object().shape({
timeout: Yup.string().required('Required'),
author: Yup.string().nullable(),
tags: Yup.array<string[]>().nullable(),
usesConsumerParameters: Yup.boolean(),
consumerParameters: Yup.array().when('usesConsumerParameters', {
is: true,
then: Yup.array()
.of(Yup.object().shape(validationConsumerParameters))
.required('Required'),
otherwise: Yup.array()
.nullable()
.transform((value) => value || null)
}),
paymentCollector: Yup.string().test(
'ValidAddress',
'Must be a valid Ethereum Address.',
@ -44,7 +55,19 @@ export const validationSchema = Yup.object().shape({
return isAddress(value)
}
),
retireAsset: Yup.string()
retireAsset: Yup.string(),
service: Yup.object().shape({
usesConsumerParameters: Yup.boolean(),
consumerParameters: Yup.array().when('usesConsumerParameters', {
is: true,
then: Yup.array()
.of(Yup.object().shape(validationConsumerParameters))
.required('Required'),
otherwise: Yup.array()
.nullable()
.transform((value) => value || null)
})
})
})
export const computeSettingsValidationSchema = Yup.object().shape({

View File

@ -3,6 +3,7 @@ import Input from '@shared/FormInput'
import { Field, useField, useFormikContext } from 'formik'
import React, { ReactElement, useEffect } from 'react'
import content from '../../../../content/publish/form.json'
import consumerParametersContent from '../../../../content/publish/consumerParameters.json'
import { FormPublishData } from '../_types'
import IconDataset from '@images/dataset.svg'
import IconAlgorithm from '@images/algorithm.svg'
@ -138,6 +139,24 @@ export default function MetadataFields(): ReactElement {
/>
</>
)}
<Field
{...getFieldContent(
'usesConsumerParameters',
content.metadata.fields
)}
component={Input}
name="metadata.usesConsumerParameters"
/>
{values.metadata.usesConsumerParameters && (
<Field
{...getFieldContent(
'consumerParameters',
consumerParametersContent.consumerParameters.fields
)}
component={Input}
name="metadata.consumerParameters"
/>
)}
</>
)}

View File

@ -4,6 +4,7 @@ import React, { ReactElement, useEffect } from 'react'
import IconDownload from '@images/download.svg'
import IconCompute from '@images/compute.svg'
import content from '../../../../content/publish/form.json'
import consumerParametersContent from '../../../../content/publish/consumerParameters.json'
import { getFieldContent } from '@utils/form'
import { FormPublishData } from '../_types'
import Alert from '@shared/atoms/Alert'
@ -101,6 +102,21 @@ export default function ServicesFields(): ReactElement {
component={Input}
name="services[0].timeout"
/>
<Field
{...getFieldContent('usesConsumerParameters', content.services.fields)}
component={Input}
name="services[0].usesConsumerParameters"
/>
{values.services[0].usesConsumerParameters && (
<Field
{...getFieldContent(
'consumerParameters',
consumerParametersContent.consumerParameters.fields
)}
component={Input}
name="services[0].consumerParameters"
/>
)}
</>
)
}

View File

@ -68,7 +68,9 @@ export const initialValues: FormPublishData = {
dockerImage: '',
dockerImageCustom: '',
dockerImageCustomTag: '',
dockerImageCustomEntrypoint: ''
dockerImageCustomEntrypoint: '',
usesConsumerParameters: false,
consumerParameters: []
},
services: [
{
@ -82,7 +84,9 @@ export const initialValues: FormPublishData = {
valid: true,
custom: false
},
computeOptions
computeOptions,
usesConsumerParameters: false,
consumerParameters: []
}
],
pricing: {

View File

@ -10,6 +10,8 @@ export interface FormPublishService {
providerUrl: { url: string; valid: boolean; custom: boolean }
algorithmPrivacy?: boolean
computeOptions?: ServiceComputeOptions
usesConsumerParameters?: boolean
consumerParameters?: FormConsumerParameter[]
}
export interface FormPublishData {
@ -32,6 +34,12 @@ export interface FormPublishData {
dockerImageCustomTag?: string
dockerImageCustomEntrypoint?: string
dockerImageCustomChecksum?: string
usesConsumerParameters?: boolean
consumerParameters?: FormConsumerParameter[]
service?: {
usesConsumerParameters?: boolean
consumerParameters?: FormConsumerParameter[]
}
}
services: FormPublishService[]
pricing: PricePublishOptions
@ -61,3 +69,14 @@ export interface MetadataAlgorithmContainer {
tag: string
checksum: string
}
export interface FormConsumerParameter {
name: string
type: 'text' | 'number' | 'boolean' | 'select'
label: string
required: string
description: string
default: string | boolean | number
options?: { key: string; value: string }[]
value?: string | boolean | number
}

View File

@ -7,19 +7,24 @@ import {
DispenserCreationParams,
getHash,
LoggerInstance,
Metadata,
NftCreateData,
NftFactory,
Service,
ZERO_ADDRESS,
getEventFromTx
getEventFromTx,
ConsumerParameter,
Metadata,
Service
} from '@oceanprotocol/lib'
import { mapTimeoutStringToSeconds, normalizeFile } from '@utils/ddo'
import { generateNftCreateData } from '@utils/nft'
import { getEncryptedFiles } from '@utils/provider'
import slugify from 'slugify'
import { algorithmContainerPresets } from './_constants'
import { FormPublishData, MetadataAlgorithmContainer } from './_types'
import {
FormConsumerParameter,
FormPublishData,
MetadataAlgorithmContainer
} from './_types'
import {
marketFeeAddress,
publisherMarketOrderFee,
@ -59,6 +64,33 @@ function transformTags(originalTags: string[]): string[] {
return transformedTags
}
export function transformConsumerParameters(
parameters: FormConsumerParameter[]
): ConsumerParameter[] {
if (!parameters?.length) return
const transformedValues = parameters.map((param) => {
const options =
param.type === 'select'
? // Transform from { key: string, value: string } into { key: value }
JSON.stringify(
param.options?.map((opt) => ({ [opt.key]: opt.value }))
)
: undefined
const required = param.required === 'required'
return {
...param,
options,
required,
default: param.default.toString()
}
})
return transformedValues as ConsumerParameter[]
}
export async function transformPublishFormToDdo(
values: FormPublishData,
// Those 2 are only passed during actual publishing process
@ -79,7 +111,9 @@ export async function transformPublishFormToDdo(
dockerImageCustom,
dockerImageCustomTag,
dockerImageCustomEntrypoint,
dockerImageCustomChecksum
dockerImageCustomChecksum,
usesConsumerParameters,
consumerParameters
} = metadata
const { access, files, links, providerUrl, timeout } = services[0]
@ -98,6 +132,10 @@ export async function transformPublishFormToDdo(
const linksTransformed = links?.length &&
links[0].valid && [sanitizeUrl(links[0].url)]
const consumerParametersTransformed = usesConsumerParameters
? transformConsumerParameters(consumerParameters)
: undefined
const newMetadata: Metadata = {
created: currentTime,
updated: currentTime,
@ -135,7 +173,8 @@ export async function transformPublishFormToDdo(
dockerImage === 'custom'
? dockerImageCustomChecksum
: algorithmContainerPresets.checksum
}
},
consumerParameters: consumerParametersTransformed
}
})
}
@ -161,7 +200,10 @@ export async function transformPublishFormToDdo(
timeout: mapTimeoutStringToSeconds(timeout),
...(access === 'compute' && {
compute: values.services[0].computeOptions
})
}),
consumerParameters: values.services[0].usesConsumerParameters
? transformConsumerParameters(values.services[0].consumerParameters)
: undefined
}
const newDdo: DDO = {

View File

@ -1,8 +1,9 @@
import { MAX_DECIMALS } from '@utils/constants'
import * as Yup from 'yup'
import { getMaxDecimalsValidation } from '@utils/numbers'
import { FileInfo } from '@oceanprotocol/lib'
import { testLinks } from '../../@utils/yup'
import { MAX_DECIMALS } from '@utils/constants'
import { getMaxDecimalsValidation } from '@utils/numbers'
import * as Yup from 'yup'
import { testLinks } from '@utils/yup'
import { validationConsumerParameters } from '@components/@shared/FormInput/InputElement/ConsumerParameters/_validation'
// TODO: conditional validation
// e.g. when algo is selected, Docker image is required
@ -26,7 +27,17 @@ const validationMetadata = {
tags: Yup.array<string[]>().nullable(),
termsAndConditions: Yup.boolean()
.required('Required')
.isTrue('Please agree to the Terms and Conditions.')
.isTrue('Please agree to the Terms and Conditions.'),
usesConsumerParameters: Yup.boolean(),
consumerParameters: Yup.array().when('usesConsumerParameters', {
is: true,
then: Yup.array()
.of(Yup.object().shape(validationConsumerParameters))
.required('Required'),
otherwise: Yup.array()
.nullable()
.transform((value) => value || null)
})
}
const validationService = {
@ -60,6 +71,16 @@ const validationService = {
url: Yup.string().url('Must be a valid URL.').required('Required'),
valid: Yup.boolean().isTrue().required('Valid Provider is required.'),
custom: Yup.boolean()
}),
usesConsumerParameters: Yup.boolean(),
consumerParameters: Yup.array().when('usesConsumerParameters', {
is: true,
then: Yup.array()
.of(Yup.object().shape(validationConsumerParameters))
.required('Required'),
otherwise: Yup.array()
.nullable()
.transform((value) => value || null)
})
}