From fefb42aa079dd1ff8389d4d3c5c55e5a7be57eaa Mon Sep 17 00:00:00 2001 From: Moritz Kirstein Date: Thu, 7 Sep 2023 16:14:27 +0200 Subject: [PATCH] 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 * Update src/components/Asset/AssetActions/ConsumerParameters/FormConsumerParameters.module.css Co-authored-by: Jamie Hewitt * Update src/components/Asset/Edit/_validation.ts Co-authored-by: Jamie Hewitt * Update src/components/Asset/AssetActions/ConsumerParameters/index.tsx Co-authored-by: Jamie Hewitt * Update src/components/Asset/AssetActions/ConsumerParameters/FormConsumerParameters.tsx Co-authored-by: Jamie Hewitt * Update src/components/@shared/FormInput/index.tsx Co-authored-by: Jamie Hewitt * Update src/components/@shared/FormInput/InputElement/ConsumerParameters/index.tsx Co-authored-by: Jamie Hewitt * Update src/components/@shared/FormInput/InputElement/ConsumerParameters/index.tsx Co-authored-by: Jamie Hewitt * Update src/components/@shared/FormInput/InputElement/ConsumerParameters/OptionsInput.tsx Co-authored-by: Jamie Hewitt * Update src/components/@shared/FormInput/InputElement/ConsumerParameters/FormActions.tsx Co-authored-by: Jamie Hewitt * Update src/@utils/provider.ts Co-authored-by: Jamie Hewitt * 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 Co-authored-by: Jamie Hewitt --- content/pages/editMetadata.json | 16 +++ content/publish/consumerParameters.json | 66 +++++++++++ content/publish/form.json | 16 +++ src/@types/AssetExtended.d.ts | 2 + src/@utils/ddo.ts | 33 +++++- src/@utils/index.ts | 24 ++++ src/@utils/provider.ts | 11 +- .../ConsumerParameters/DefaultInput.tsx | 40 +++++++ .../ConsumerParameters/FormActions.module.css | 5 + .../ConsumerParameters/FormActions.tsx | 84 +++++++++++++ .../OptionsInput.module.css | 16 +++ .../ConsumerParameters/OptionsInput.tsx | 36 ++++++ .../ConsumerParameters/TypeInput.tsx | 53 +++++++++ .../ConsumerParameters/_validation.ts | 62 ++++++++++ .../ConsumerParameters/index.module.css | 4 + .../InputElement/ConsumerParameters/index.tsx | 110 ++++++++++++++++++ .../InputElement/FilesInput/index.tsx | 6 +- .../InputElement/Headers/index.module.css | 13 --- .../KeyValueInput/index.module.css | 35 ++++++ .../{Headers => KeyValueInput}/index.tsx | 98 +++++++++++----- .../@shared/FormInput/InputElement/index.tsx | 4 + src/components/@shared/FormInput/index.tsx | 36 +++--- src/components/@shared/atoms/Tabs/index.tsx | 16 ++- .../Compute/FormComputeDataset.tsx | 23 +++- .../Compute/PriceOutput.module.css | 1 - .../Asset/AssetActions/Compute/_constants.ts | 44 ++++++- .../Asset/AssetActions/Compute/index.tsx | 58 +++++++-- .../FormConsumerParameters.module.css | 39 +++++++ .../FormConsumerParameters.tsx | 78 +++++++++++++ .../ConsumerParameters/_validation.ts | 28 +++++ .../ConsumerParameters/index.module.css | 8 ++ .../AssetActions/ConsumerParameters/index.tsx | 97 +++++++++++++++ .../AssetActions/Download/_validation.ts | 13 +++ .../index.module.css} | 16 +++ .../{Download.tsx => Download/index.tsx} | 109 +++++++++++------ src/components/Asset/AssetActions/index.tsx | 1 - src/components/Asset/Edit/EditMetadata.tsx | 21 +++- .../Asset/Edit/FormEditMetadata.module.css | 4 + .../Asset/Edit/FormEditMetadata.tsx | 44 +++++++ src/components/Asset/Edit/_constants.ts | 18 ++- src/components/Asset/Edit/_types.ts | 7 ++ src/components/Asset/Edit/_validation.ts | 27 ++++- src/components/Publish/Metadata/index.tsx | 19 +++ src/components/Publish/Services/index.tsx | 16 +++ src/components/Publish/_constants.tsx | 8 +- src/components/Publish/_types.ts | 19 +++ src/components/Publish/_utils.ts | 56 +++++++-- src/components/Publish/_validation.ts | 31 ++++- 48 files changed, 1415 insertions(+), 156 deletions(-) create mode 100644 content/publish/consumerParameters.json create mode 100644 src/components/@shared/FormInput/InputElement/ConsumerParameters/DefaultInput.tsx create mode 100644 src/components/@shared/FormInput/InputElement/ConsumerParameters/FormActions.module.css create mode 100644 src/components/@shared/FormInput/InputElement/ConsumerParameters/FormActions.tsx create mode 100644 src/components/@shared/FormInput/InputElement/ConsumerParameters/OptionsInput.module.css create mode 100644 src/components/@shared/FormInput/InputElement/ConsumerParameters/OptionsInput.tsx create mode 100644 src/components/@shared/FormInput/InputElement/ConsumerParameters/TypeInput.tsx create mode 100644 src/components/@shared/FormInput/InputElement/ConsumerParameters/_validation.ts create mode 100644 src/components/@shared/FormInput/InputElement/ConsumerParameters/index.module.css create mode 100644 src/components/@shared/FormInput/InputElement/ConsumerParameters/index.tsx delete mode 100644 src/components/@shared/FormInput/InputElement/Headers/index.module.css create mode 100644 src/components/@shared/FormInput/InputElement/KeyValueInput/index.module.css rename src/components/@shared/FormInput/InputElement/{Headers => KeyValueInput}/index.tsx (52%) create mode 100644 src/components/Asset/AssetActions/ConsumerParameters/FormConsumerParameters.module.css create mode 100644 src/components/Asset/AssetActions/ConsumerParameters/FormConsumerParameters.tsx create mode 100644 src/components/Asset/AssetActions/ConsumerParameters/_validation.ts create mode 100644 src/components/Asset/AssetActions/ConsumerParameters/index.module.css create mode 100644 src/components/Asset/AssetActions/ConsumerParameters/index.tsx create mode 100644 src/components/Asset/AssetActions/Download/_validation.ts rename src/components/Asset/AssetActions/{Download.module.css => Download/index.module.css} (56%) rename src/components/Asset/AssetActions/{Download.tsx => Download/index.tsx} (73%) create mode 100644 src/components/Asset/Edit/FormEditMetadata.module.css diff --git a/content/pages/editMetadata.json b/content/pages/editMetadata.json index ed40f9b78..9d7a0a04e 100644 --- a/content/pages/editMetadata.json +++ b/content/pages/editMetadata.json @@ -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 } ] } diff --git a/content/publish/consumerParameters.json b/content/publish/consumerParameters.json new file mode 100644 index 000000000..e17db34b2 --- /dev/null +++ b/content/publish/consumerParameters.json @@ -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 + } + ] + } + ] + } +} diff --git a/content/publish/form.json b/content/publish/form.json index 2d1b1b92e..283ca606d 100644 --- a/content/publish/form.json +++ b/content/publish/form.json @@ -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 } ] }, diff --git a/src/@types/AssetExtended.d.ts b/src/@types/AssetExtended.d.ts index d6ee6ade1..71d63e265 100644 --- a/src/@types/AssetExtended.d.ts +++ b/src/@types/AssetExtended.d.ts @@ -6,5 +6,7 @@ declare global { interface AssetExtended extends Asset { accessDetails?: AccessDetails views?: number + metadata: MetadataExtended + services: ServiceExtended[] } } diff --git a/src/@utils/ddo.ts b/src/@utils/ddo.ts index 61e95b1a4..b08795586 100644 --- a/src/@utils/ddo.ts +++ b/src/@utils/ddo.ts @@ -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 + })) +} diff --git a/src/@utils/index.ts b/src/@utils/index.ts index 9d3e5f2af..5777f65f3 100644 --- a/src/@utils/index.ts +++ b/src/@utils/index.ts @@ -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 +} diff --git a/src/@utils/provider.ts b/src/@utils/provider.ts index d32013a7c..c16cc85a5 100644 --- a/src/@utils/provider.ts +++ b/src/@utils/provider.ts @@ -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)) diff --git a/src/components/@shared/FormInput/InputElement/ConsumerParameters/DefaultInput.tsx b/src/components/@shared/FormInput/InputElement/ConsumerParameters/DefaultInput.tsx new file mode 100644 index 000000000..242ac8e8d --- /dev/null +++ b/src/components/@shared/FormInput/InputElement/ConsumerParameters/DefaultInput.tsx @@ -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(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 ( + + ) +} diff --git a/src/components/@shared/FormInput/InputElement/ConsumerParameters/FormActions.module.css b/src/components/@shared/FormInput/InputElement/ConsumerParameters/FormActions.module.css new file mode 100644 index 000000000..eaf57e702 --- /dev/null +++ b/src/components/@shared/FormInput/InputElement/ConsumerParameters/FormActions.module.css @@ -0,0 +1,5 @@ +.actions { + display: flex; + justify-content: center; + gap: var(--spacer); +} diff --git a/src/components/@shared/FormInput/InputElement/ConsumerParameters/FormActions.tsx b/src/components/@shared/FormInput/InputElement/ConsumerParameters/FormActions.tsx new file mode 100644 index 000000000..faa4e65bc --- /dev/null +++ b/src/components/@shared/FormInput/InputElement/ConsumerParameters/FormActions.tsx @@ -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() + const [field, meta, helpers] = useField(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 ( +
+ + +
+ ) +} diff --git a/src/components/@shared/FormInput/InputElement/ConsumerParameters/OptionsInput.module.css b/src/components/@shared/FormInput/InputElement/ConsumerParameters/OptionsInput.module.css new file mode 100644 index 000000000..578d85dbf --- /dev/null +++ b/src/components/@shared/FormInput/InputElement/ConsumerParameters/OptionsInput.module.css @@ -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%; +} diff --git a/src/components/@shared/FormInput/InputElement/ConsumerParameters/OptionsInput.tsx b/src/components/@shared/FormInput/InputElement/ConsumerParameters/OptionsInput.tsx new file mode 100644 index 000000000..b254f39a0 --- /dev/null +++ b/src/components/@shared/FormInput/InputElement/ConsumerParameters/OptionsInput.tsx @@ -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() + + const hasError = (): boolean => + [errors, touched].every( + (object) => !!getObjectPropertyByPath(object, props.name) + ) + + return ( +
+ + + {hasError() && ( +
+ +
+ )} +
+ ) +} diff --git a/src/components/@shared/FormInput/InputElement/ConsumerParameters/TypeInput.tsx b/src/components/@shared/FormInput/InputElement/ConsumerParameters/TypeInput.tsx new file mode 100644 index 000000000..739d62856 --- /dev/null +++ b/src/components/@shared/FormInput/InputElement/ConsumerParameters/TypeInput.tsx @@ -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() + const [field, meta, helpers] = useField(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 ( + { + resetDefaultValue(props.name, e.target.value, index) + field.onChange(e) + }} + /> + ) +} diff --git a/src/components/@shared/FormInput/InputElement/ConsumerParameters/_validation.ts b/src/components/@shared/FormInput/InputElement/ConsumerParameters/_validation.ts new file mode 100644 index 000000000..518ecb257 --- /dev/null +++ b/src/components/@shared/FormInput/InputElement/ConsumerParameters/_validation.ts @@ -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() + }) +} diff --git a/src/components/@shared/FormInput/InputElement/ConsumerParameters/index.module.css b/src/components/@shared/FormInput/InputElement/ConsumerParameters/index.module.css new file mode 100644 index 000000000..0f155a9bd --- /dev/null +++ b/src/components/@shared/FormInput/InputElement/ConsumerParameters/index.module.css @@ -0,0 +1,4 @@ +.container { + border: 1px solid var(--border-color); + border-radius: var(--border-radius); +} diff --git a/src/components/@shared/FormInput/InputElement/ConsumerParameters/index.tsx b/src/components/@shared/FormInput/InputElement/ConsumerParameters/index.tsx new file mode 100644 index 000000000..aa60d7f09 --- /dev/null +++ b/src/components/@shared/FormInput/InputElement/ConsumerParameters/index.tsx @@ -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(props.name) + + const [tabIndex, setTabIndex] = useState(0) + + useEffect(() => { + if (field.value.length === 0) + helpers.setValue([{ ...defaultConsumerParam }]) + }, []) + + return ( +
+ { + return { + title: + param?.name?.length > 15 + ? `${param?.name?.slice(0, 15)}...` + : param?.name || 'New parameter', + content: ( +
+ {props.fields?.map((subField: InputProps) => { + if (subField.name === 'options') { + return field.value[index]?.type === 'select' ? ( + + ) : null + } + + if (subField.name === 'default') { + return ( + + ) + } + + if (subField.name === 'type') { + return ( + + ) + } + + return ( + + ) + })} + +
+ ) + } + })} + /> +
+ ) +} diff --git a/src/components/@shared/FormInput/InputElement/FilesInput/index.tsx b/src/components/@shared/FormInput/InputElement/FilesInput/index.tsx index eda4da4b1..37bd5f8d5 100644 --- a/src/components/@shared/FormInput/InputElement/FilesInput/index.tsx +++ b/src/components/@shared/FormInput/InputElement/FilesInput/index.tsx @@ -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 { { + 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 ( -
+
-
+
@@ -92,26 +132,32 @@ export default function InputHeaders(props: InputProps): ReactElement { size="small" onClick={(e: React.SyntheticEvent) => { e.preventDefault() - addHeader() + addPair() }} disabled={disabledButton} > add + + {uniqueKeys && !hasOnlyUniqueKeys && ( +

{`The ${keyPlaceholder} field must be unique`}

+ )}
- {headers.length > 0 && - headers.map((header, i) => { + {pairs.length > 0 && + pairs.map((header, i) => { return ( -
+
@@ -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} > diff --git a/src/components/@shared/FormInput/InputElement/index.tsx b/src/components/@shared/FormInput/InputElement/index.tsx index c21e29ba7..a803015e2 100644 --- a/src/components/@shared/FormInput/InputElement/index.tsx +++ b/src/components/@shared/FormInput/InputElement/index.tsx @@ -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 + case 'textarea': return