diff --git a/hub/src/components/form/Multiselect/index.tsx b/hub/src/components/form/Multiselect/index.tsx new file mode 100644 index 000000000..d57da8970 --- /dev/null +++ b/hub/src/components/form/Multiselect/index.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { Controller, FieldValues } from 'react-hook-form'; +import FormField from '@cloudscape-design/components/form-field'; +import MultiselectCSD from '@cloudscape-design/components/multiselect'; +import { MultiselectProps } from '@cloudscape-design/components/multiselect/interfaces'; + +import { FormMultiselectProps } from './types'; + +export const FormMultiselect = ({ + name, + rules, + control, + label, + info, + constraintText, + description, + secondaryControl, + stretch, + onChange: onChangeProp, + ...props +}: FormMultiselectProps) => { + return ( + { + const selectedOptions = props.options?.filter((i) => fieldRest.value.includes(i.value)) ?? null; + + const onChangeSelect: MultiselectProps['onChange'] = (event) => { + const value = event.detail.selectedOptions.map((item) => item.value); + onChange(value); + onChangeProp && onChangeProp(event); + }; + + return ( + + + + ); + }} + /> + ); +}; diff --git a/hub/src/components/form/Multiselect/types.ts b/hub/src/components/form/Multiselect/types.ts new file mode 100644 index 000000000..310bc0bf4 --- /dev/null +++ b/hub/src/components/form/Multiselect/types.ts @@ -0,0 +1,15 @@ +import { ControllerProps, FieldValues } from 'react-hook-form'; +import { FormFieldProps } from '@cloudscape-design/components/form-field'; +import { MultiselectProps } from '@cloudscape-design/components/multiselect'; + +export type FormMultiselectOption = MultiselectProps.Option; +export type FormMultiselectOptions = ReadonlyArray; + +export type FormMultiselectProps = Omit< + MultiselectProps, + 'value' | 'name' | 'selectedOptions' | 'options' +> & + Omit & + Pick, 'control' | 'name' | 'rules'> & { + options: ReadonlyArray; + }; diff --git a/hub/src/components/index.ts b/hub/src/components/index.ts index 012b4039b..ed982e44e 100644 --- a/hub/src/components/index.ts +++ b/hub/src/components/index.ts @@ -44,10 +44,12 @@ export { ListEmptyMessage } from './ListEmptyMessage'; export { DetailsHeader } from './DetailsHeader'; export { Loader } from './Loader'; export { FormInput } from './form/Input'; +export { FormMultiselect } from './form/Multiselect'; export { FormSelect } from './form/Select'; export { FormTextarea } from './form/Textarea'; export { FormRadioButtons } from './form/RadioButtons'; export type { FormSelectOptions, FormSelectProps } from './form/Select/types'; +export type { FormMultiselectOptions, FormMultiselectProps } from './form/Multiselect/types'; export { FormS3BucketSelector } from './form/S3BucketSelector'; export type { FormTilesProps } from './form/Tiles/types'; export { FormTiles } from './form/Tiles'; diff --git a/hub/src/locale/en.json b/hub/src/locale/en.json index de5bf486d..6b1407f6a 100644 --- a/hub/src/locale/en.json +++ b/hub/src/locale/en.json @@ -66,6 +66,8 @@ "gcp_description": "Run workflows and store data in Google Cloud Platform", "azure": "Azure", "azure_description": "Run workflows and store data in Microsoft Azure", + "lambda": "Lambda", + "lambda_description": "Run workflows and store data in Lambda", "local": "Local", "local_description": "Run workflows and store data locally via Docker" }, @@ -145,6 +147,26 @@ "subnet_description": "Select a subnet to run workflows in", "subnet_placeholder": "Not selected" }, + "lambda": { + "api_key": "Api key", + "api_key_description": "Specify the Lambda api key", + "regions": "Regions", + "regions_description": "Select regions to run workflows and store artifacts", + "regions_placeholder": "Select regions", + "storage_backend": { + "type": "Type", + "type_description": "Type of backend storage", + "type_placeholder": "Select type", + "credentials": { + "access_key_id": "Access key ID", + "access_key_id_description": "Specify the AWS access key ID", + "secret_key_id": "Secret access key", + "secret_key_id_description": "Specify the AWS secret access key" + }, + "s3_bucket_name": "Bucket", + "s3_bucket_name_description": "Select an S3 bucket to store artifacts" + } + }, "local": { "path": "Files path" }, diff --git a/hub/src/pages/Project/Details/Settings/index.tsx b/hub/src/pages/Project/Details/Settings/index.tsx index 48e644560..cd9af40f4 100644 --- a/hub/src/pages/Project/Details/Settings/index.tsx +++ b/hub/src/pages/Project/Details/Settings/index.tsx @@ -15,6 +15,8 @@ import { selectAuthToken, selectUserData } from 'App/slice'; import { ProjectMembers } from '../../Members'; import { getProjectRoleByUserName } from '../../utils'; +import { BackendTypesEnum } from '../../Form/types'; + import styles from './styles.module.scss'; export const ProjectSettings: React.FC = () => { @@ -170,6 +172,34 @@ export const ProjectSettings: React.FC = () => { ); }; + const renderLambdaBackendDetails = (): React.ReactNode => { + if (!data) return null; + + return ( + +
+ {t('projects.edit.backend_type')} +
{t(`projects.backend_type.${data.backend.type}`)}
+
+ +
+ {t('projects.edit.lambda.regions')} +
{data.backend.regions.join(', ')}
+
+ +
+ {t('projects.edit.lambda.storage_backend.type')} +
{data.backend.storage_backend.type}
+
+ +
+ {t('projects.edit.lambda.storage_backend.s3_bucket_name')} +
{data.backend.storage_backend.bucket_name}
+
+
+ ); + }; + const renderLocalBackendDetails = (): React.ReactNode => { if (!data) return null; @@ -190,15 +220,18 @@ export const ProjectSettings: React.FC = () => { const renderBackendDetails = () => { switch (data?.backend.type) { - case 'aws': { + case BackendTypesEnum.AWS: { return renderAwsBackendDetails(); } - case 'azure': { + case BackendTypesEnum.AZURE: { return renderAzureBackendDetails(); } - case 'gcp': { + case BackendTypesEnum.GCP: { return renderGCPBackendDetails(); } + case BackendTypesEnum.LAMBDA: { + return renderLambdaBackendDetails(); + } case 'local': { return renderLocalBackendDetails(); } diff --git a/hub/src/pages/Project/Form/Lambda/constants.tsx b/hub/src/pages/Project/Form/Lambda/constants.tsx new file mode 100644 index 000000000..53da8c32c --- /dev/null +++ b/hub/src/pages/Project/Form/Lambda/constants.tsx @@ -0,0 +1,35 @@ +import React from 'react'; + +export const FIELD_NAMES = { + API_KEY: 'api_key', + REGIONS: 'regions', + STORAGE_BACKEND: { + TYPE: 'storage_backend.type', + BUCKET_NAME: 'storage_backend.bucket_name', + CREDENTIALS: { + ACCESS_KEY: 'storage_backend.credentials.access_key', + SECRET_KEY: 'storage_backend.credentials.secret_key', + }, + }, +}; + +export const DEFAULT_HELP = { + header:

HELP TITLE

, + body: ( + <> +

Help description

+ + ), + + footer: ( + <> +

Help footer

+ + + + ), +}; diff --git a/hub/src/pages/Project/Form/Lambda/index.tsx b/hub/src/pages/Project/Form/Lambda/index.tsx new file mode 100644 index 000000000..eae0c3f33 --- /dev/null +++ b/hub/src/pages/Project/Form/Lambda/index.tsx @@ -0,0 +1,235 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useFormContext } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { debounce } from 'lodash'; + +import { + FormInput, + FormMultiselect, + FormMultiselectProps, + FormS3BucketSelector, + FormSelect, + FormSelectOptions, + InfoLink, + SpaceBetween, + Spinner, +} from 'components'; + +import { useHelpPanel, useNotifications } from 'hooks'; +import { isRequestFormErrors2, isRequestFormFieldError } from 'libs'; +import { useBackendValuesMutation } from 'services/project'; + +import { DEFAULT_HELP, FIELD_NAMES } from './constants'; + +import { IProps } from './types'; + +import styles from '../AWS/styles.module.scss'; + +export const LambdaBackend: React.FC = ({ loading }) => { + const { t } = useTranslation(); + const [pushNotification] = useNotifications(); + const { control, getValues, setValue, setError, clearErrors } = useFormContext(); + const [valuesData, setValuesData] = useState(); + const [regions, setRegions] = useState([]); + const [buckets, setBuckets] = useState([]); + const [storageBackendType, setStorageBackendType] = useState([]); + const lastUpdatedField = useRef(null); + const isFirstRender = useRef(true); + + const [getBackendValues, { isLoading: isLoadingValues }] = useBackendValuesMutation(); + + const requestRef = useRef>(null); + + const [openHelpPanel] = useHelpPanel(); + + const changeFormHandler = async () => { + const backendFormValues = getValues('backend'); + + if (!backendFormValues.api_key) { + return; + } + + clearErrors('backend'); + + try { + const request = getBackendValues(backendFormValues); + requestRef.current = request; + + const response = await request.unwrap(); + + setValuesData(response); + + lastUpdatedField.current = null; + + if (response.regions?.values) { + setRegions(response.regions.values); + } + + if (response.regions?.selected !== undefined) { + setValue(`backend.${FIELD_NAMES.REGIONS}`, response.regions.selected); + } + + if (response.storage_backend_type?.values) { + setStorageBackendType(response.storage_backend_type.values); + } + + if (response.storage_backend_type?.selected !== undefined) { + setValue(`backend.${FIELD_NAMES.STORAGE_BACKEND.TYPE}`, response.storage_backend_type.selected); + } + + if (response.storage_backend_values?.bucket_name?.values) { + const formattedBuckets = response.storage_backend_values.bucket_name.values.map(({ value }) => ({ + name: value, + })); + + setBuckets(formattedBuckets); + } + + if (response.storage_backend_values?.bucket_name?.selected !== undefined) { + setValue( + `backend.${FIELD_NAMES.STORAGE_BACKEND.BUCKET_NAME}`, + response.storage_backend_values.bucket_name.selected, + ); + } + } catch (errorResponse) { + console.log('fetch backends values error:', errorResponse); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const errorRequestData = errorResponse?.data; + + if (isRequestFormErrors2(errorRequestData)) { + errorRequestData.detail.forEach((error) => { + if (isRequestFormFieldError(error)) { + setError(`backend.${error.loc.join('.')}`, { type: 'custom', message: error.msg }); + } else { + pushNotification({ + type: 'error', + content: t('common.server_error', { error: error?.msg }), + }); + } + }); + } + } + }; + + useEffect(() => { + if (!isFirstRender.current) return; + + changeFormHandler().catch(console.log); + isFirstRender.current = false; + }, []); + + const debouncedChangeFormHandler = useCallback(debounce(changeFormHandler, 1000), []); + + const getOnChangeSelectField = (fieldName: string) => () => { + lastUpdatedField.current = fieldName; + if (requestRef.current) requestRef.current.abort(); + changeFormHandler().catch(console.log); + }; + const onChangeCredentialField = () => { + if (requestRef.current) requestRef.current.abort(); + debouncedChangeFormHandler(); + }; + + const renderSpinner = (force?: boolean) => { + if (isLoadingValues || force) + return ( +
+ +
+ ); + }; + + const getDisabledByFieldName = (fieldName: string) => { + let disabledField = loading || !valuesData; + + disabledField = disabledField || (lastUpdatedField.current !== fieldName && isLoadingValues); + + return disabledField; + }; + + return ( + + openHelpPanel(DEFAULT_HELP)} />} + label={t('projects.edit.lambda.api_key')} + description={t('projects.edit.lambda.api_key_description')} + control={control} + name={`backend.${FIELD_NAMES.API_KEY}`} + onChange={onChangeCredentialField} + disabled={loading} + rules={{ required: t('validation.required') }} + autoComplete="off" + /> + + openHelpPanel(DEFAULT_HELP)} />} + label={t('projects.edit.lambda.regions')} + description={t('projects.edit.lambda.regions_description')} + placeholder={t('projects.edit.lambda.regions_placeholder')} + control={control} + name={`backend.${FIELD_NAMES.REGIONS}`} + onChange={getOnChangeSelectField(FIELD_NAMES.REGIONS)} + disabled={getDisabledByFieldName(FIELD_NAMES.REGIONS)} + secondaryControl={renderSpinner()} + options={regions} + /> + + openHelpPanel(DEFAULT_HELP)} />} + label={t('projects.edit.lambda.storage_backend.type')} + description={t('projects.edit.lambda.storage_backend.type_description')} + placeholder={t('projects.edit.lambda.storage_backend.type_placeholder')} + control={control} + name={`backend.${FIELD_NAMES.STORAGE_BACKEND.TYPE}`} + disabled={getDisabledByFieldName(FIELD_NAMES.STORAGE_BACKEND.TYPE)} + onChange={getOnChangeSelectField(FIELD_NAMES.STORAGE_BACKEND.TYPE)} + options={storageBackendType} + rules={{ required: t('validation.required') }} + secondaryControl={renderSpinner()} + /> + + openHelpPanel(DEFAULT_HELP)} />} + label={t('projects.edit.lambda.storage_backend.credentials.access_key_id')} + description={t('projects.edit.lambda.storage_backend.credentials.access_key_id_description')} + control={control} + name={`backend.${FIELD_NAMES.STORAGE_BACKEND.CREDENTIALS.ACCESS_KEY}`} + onChange={onChangeCredentialField} + rules={{ required: t('validation.required') }} + disabled={getDisabledByFieldName(FIELD_NAMES.STORAGE_BACKEND.CREDENTIALS.ACCESS_KEY)} + autoComplete="off" + /> + + openHelpPanel(DEFAULT_HELP)} />} + label={t('projects.edit.lambda.storage_backend.credentials.secret_key_id')} + description={t('projects.edit.lambda.storage_backend.credentials.secret_key_id_description')} + control={control} + name={`backend.${FIELD_NAMES.STORAGE_BACKEND.CREDENTIALS.SECRET_KEY}`} + onChange={onChangeCredentialField} + rules={{ required: t('validation.required') }} + disabled={getDisabledByFieldName(FIELD_NAMES.STORAGE_BACKEND.CREDENTIALS.SECRET_KEY)} + autoComplete="off" + /> + + openHelpPanel(DEFAULT_HELP)} />} + label={t('projects.edit.lambda.storage_backend.s3_bucket_name')} + description={t('projects.edit.lambda.storage_backend.s3_bucket_name_description')} + control={control} + name={`backend.${FIELD_NAMES.STORAGE_BACKEND.BUCKET_NAME}`} + selectableItemsTypes={['buckets']} + disabled={getDisabledByFieldName(FIELD_NAMES.STORAGE_BACKEND.BUCKET_NAME)} + rules={{ required: t('validation.required') }} + buckets={buckets} + secondaryControl={renderSpinner()} + i18nStrings={{ + inContextBrowseButton: 'Choose a bucket', + modalBreadcrumbRootItem: 'S3 buckets', + modalTitle: 'Choose an S3 bucket', + }} + /> + + ); +}; diff --git a/hub/src/pages/Project/Form/Lambda/types.ts b/hub/src/pages/Project/Form/Lambda/types.ts new file mode 100644 index 000000000..33b68d6dd --- /dev/null +++ b/hub/src/pages/Project/Form/Lambda/types.ts @@ -0,0 +1,3 @@ +export interface IProps { + loading?: boolean; +} diff --git a/hub/src/pages/Project/Form/index.tsx b/hub/src/pages/Project/Form/index.tsx index f82931f3e..12c02b8d9 100644 --- a/hub/src/pages/Project/Form/index.tsx +++ b/hub/src/pages/Project/Form/index.tsx @@ -25,6 +25,7 @@ import { AWSBackend } from './AWS'; import { AzureBackend } from './Azure'; import { BACKEND_TYPE_HELP } from './constants'; import { GCPBackend } from './GCP'; +import { LambdaBackend } from './Lambda'; import { BackendTypesEnum, IProps, TBackendOption } from './types'; import { FieldPath } from 'react-hook-form/dist/types/path'; @@ -139,6 +140,9 @@ export const ProjectForm: React.FC = ({ initialValues, onCancel, loading case BackendTypesEnum.GCP: { return ; } + case BackendTypesEnum.LAMBDA: { + return ; + } default: return null; } @@ -153,6 +157,9 @@ export const ProjectForm: React.FC = ({ initialValues, onCancel, loading case BackendTypesEnum.GCP: { return renderUnsupportedBackedMessage('GCP', BackendTypesEnum.GCP); } + case BackendTypesEnum.LAMBDA: { + return renderUnsupportedBackedMessage('Lambda', BackendTypesEnum.LAMBDA); + } default: return ( diff --git a/hub/src/pages/Project/Form/types.ts b/hub/src/pages/Project/Form/types.ts index fc8ef2a8b..f4bede807 100644 --- a/hub/src/pages/Project/Form/types.ts +++ b/hub/src/pages/Project/Form/types.ts @@ -9,6 +9,7 @@ export enum BackendTypesEnum { AWS = 'aws', GCP = 'gcp', AZURE = 'azure', + LAMBDA = 'lambda', LOCAL = 'local', } diff --git a/hub/src/types/project.d.ts b/hub/src/types/project.d.ts index 883ea0664..77d9d237a 100644 --- a/hub/src/types/project.d.ts +++ b/hub/src/types/project.d.ts @@ -1,6 +1,6 @@ -declare type TProjectBackendType = 'aws' | 'gcp' | 'azure' | 'local'; +declare type TProjectBackendType = 'aws' | 'gcp' | 'azure' | 'lambda' | 'local'; -declare type TProjectBackend = { type: TProjectBackendType } & TProjectBackendAWSWithTitles & TProjectBackendAzure & TProjectBackendGCP & TProjectBackendLocal +declare type TProjectBackend = { type: TProjectBackendType } & TProjectBackendAWSWithTitles & TProjectBackendAzure & TProjectBackendGCP & TProjectBackendLambda & TProjectBackendLocal declare interface IProject { project_name: string, backend: TProjectBackend, @@ -73,7 +73,27 @@ declare interface IProjectGCPBackendValues { }, } -declare type TProjectBackendValuesResponse = IProjectAwsBackendValues & IProjectAzureBackendValues & IProjectGCPBackendValues +declare interface IProjectLambdaBackendValues { + regions: null | { + selected?: string, + values: { value: string, label: string}[] + }, + + storage_backend_type: null | { + selected?: string, + values: { value: string, label: string}[] + }, + + storage_backend_values: null | { + bucket_name: null | { + selected?: string, + values: { value: string, label: string}[] + // values: TAwsBucket[] + } + }, +} + +declare type TProjectBackendValuesResponse = IProjectAwsBackendValues & IProjectAzureBackendValues & IProjectGCPBackendValues & IProjectLambdaBackendValues enum AWSCredentialTypeEnum { DEFAULT = 'default', @@ -128,6 +148,22 @@ declare interface TProjectBackendGCP { subnet: string, } +declare interface TProjectBackendLambda { + api_key: string, + regions: string[], + + storage_backend: { + type: 'aws', + bucket_name: string, + + credentials: { + type: 'access_key', + access_key: string + secret_key: string + } + } +} + declare interface TProjectBackendLocal { path: string }