diff --git a/redisinsight/ui/src/components/base/icons/Icon.tsx b/redisinsight/ui/src/components/base/icons/Icon.tsx index 9b55fdb91b..b5016359d4 100644 --- a/redisinsight/ui/src/components/base/icons/Icon.tsx +++ b/redisinsight/ui/src/components/base/icons/Icon.tsx @@ -12,6 +12,7 @@ type BaseIconProps = Omit & { | (string & {}) size?: IconSizeType | null isSvg?: boolean + style?: React.CSSProperties } const sizesMap = { @@ -43,6 +44,7 @@ export const Icon = ({ color = 'primary600', size, className, + style = {}, ...rest }: BaseIconProps) => { let sizeValue: number | string | undefined @@ -73,7 +75,13 @@ export const Icon = ({ ? svgProps : { color, customColor, size, customSize, ...rest } - return + return ( + + ) } export type IconProps = Omit diff --git a/redisinsight/ui/src/components/yaml-validator/validatePipeline.test.ts b/redisinsight/ui/src/components/yaml-validator/validatePipeline.test.ts index 6427ab8cfa..aace8b7899 100644 --- a/redisinsight/ui/src/components/yaml-validator/validatePipeline.test.ts +++ b/redisinsight/ui/src/components/yaml-validator/validatePipeline.test.ts @@ -1,6 +1,6 @@ import { get } from 'lodash' import { validatePipeline } from './validatePipeline' -import { validateYamlSchema } from './validateYamlSchema' +import { validateYamlSchema, validateSchema } from './validateYamlSchema' jest.mock('./validateYamlSchema') @@ -31,10 +31,16 @@ describe('validatePipeline', () => { valid: true, errors: [], })) + ;(validateSchema as jest.Mock).mockImplementation(() => ({ + valid: true, + errors: [], + })) const result = validatePipeline({ config: 'name: valid-config', schema: mockSchema, + monacoJobsSchema: null, + jobNameSchema: null, jobs: [ { name: 'Job1', value: 'task: job1' }, { name: 'Job2', value: 'task: job2' }, @@ -57,10 +63,16 @@ describe('validatePipeline', () => { ? { valid: false, errors: ["Missing required property 'name'"] } : { valid: true, errors: [] }, ) + ;(validateSchema as jest.Mock).mockImplementation(() => ({ + valid: true, + errors: [], + })) const result = validatePipeline({ config: 'invalid-config-content', schema: mockSchema, + monacoJobsSchema: null, + jobNameSchema: null, jobs: [{ name: 'Job1', value: 'task: job1' }], }) @@ -75,14 +87,20 @@ describe('validatePipeline', () => { it('should return invalid result when jobs are invalid', () => { ;(validateYamlSchema as jest.Mock).mockImplementation((_, schema) => - schema === get(mockSchema, 'jobs', null) + schema === null ? { valid: false, errors: ["Missing required property 'task'"] } : { valid: true, errors: [] }, ) + ;(validateSchema as jest.Mock).mockImplementation(() => ({ + valid: true, + errors: [], + })) const result = validatePipeline({ config: 'name: valid-config', schema: mockSchema, + monacoJobsSchema: null, + jobNameSchema: null, jobs: [{ name: 'Job1', value: 'invalid-job-content' }], }) @@ -100,15 +118,21 @@ describe('validatePipeline', () => { if (schema === get(mockSchema, 'config', null)) { return { valid: false, errors: ["Missing required property 'name'"] } } - if (schema === get(mockSchema, 'jobs', null)) { + if (schema === null) { return { valid: false, errors: ["Missing required property 'task'"] } } return { valid: true, errors: [] } }) + ;(validateSchema as jest.Mock).mockImplementation(() => ({ + valid: true, + errors: [], + })) const result = validatePipeline({ config: 'invalid-config-content', schema: mockSchema, + monacoJobsSchema: null, + jobNameSchema: null, jobs: [{ name: 'Job1', value: 'invalid-job-content' }], }) @@ -126,10 +150,16 @@ describe('validatePipeline', () => { valid: false, errors: ['Duplicate error', 'Duplicate error'], // all the jobs get these errors })) + ;(validateSchema as jest.Mock).mockImplementation(() => ({ + valid: true, + errors: [], + })) const result = validatePipeline({ config: 'invalid-config-content', schema: mockSchema, + monacoJobsSchema: null, + jobNameSchema: null, jobs: [ { name: 'Job1', value: 'invalid-job-content' }, { name: 'Job2', value: 'invalid-job-content' }, @@ -145,4 +175,31 @@ describe('validatePipeline', () => { }, }) }) + + it('should return invalid result when job name validation fails', () => { + ;(validateYamlSchema as jest.Mock).mockImplementation(() => ({ + valid: true, + errors: [], + })) + ;(validateSchema as jest.Mock).mockImplementation(() => ({ + valid: false, + errors: ['Job name: Invalid job name'], + })) + + const result = validatePipeline({ + config: 'name: valid-config', + schema: mockSchema, + monacoJobsSchema: null, + jobNameSchema: { type: 'string', pattern: '^[a-zA-Z]+$' }, + jobs: [{ name: 'Job-1', value: 'task: job1' }], + }) + + expect(result).toEqual({ + result: false, + configValidationErrors: [], + jobsValidationErrors: { + 'Job-1': ['Job name: Invalid job name'], + }, + }) + }) }) diff --git a/redisinsight/ui/src/components/yaml-validator/validatePipeline.ts b/redisinsight/ui/src/components/yaml-validator/validatePipeline.ts index c3eeda41b3..d7f0721d9e 100644 --- a/redisinsight/ui/src/components/yaml-validator/validatePipeline.ts +++ b/redisinsight/ui/src/components/yaml-validator/validatePipeline.ts @@ -1,15 +1,20 @@ import { get } from 'lodash' -import { validateYamlSchema } from './validateYamlSchema' +import { Nullable } from 'uiSrc/utils' +import { validateSchema, validateYamlSchema } from './validateYamlSchema' interface PipelineValidationProps { config: string schema: any + monacoJobsSchema: Nullable + jobNameSchema: Nullable jobs: { name: string; value: string }[] } export const validatePipeline = ({ config, schema, + monacoJobsSchema, + jobNameSchema, jobs, }: PipelineValidationProps) => { const { valid: isConfigValid, errors: configErrors } = validateYamlSchema( @@ -22,7 +27,11 @@ export const validatePipeline = ({ jobsErrors: Record> }>( (acc, j) => { - const validation = validateYamlSchema(j.value, get(schema, 'jobs', null)) + const validation = validateYamlSchema(j.value, monacoJobsSchema) + const jobNameValidation = validateSchema(j.name, jobNameSchema, { + errorMessagePrefix: 'Job name', + includePathIntoErrorMessage: false, + }) if (!acc.jobsErrors[j.name]) { acc.jobsErrors[j.name] = new Set() @@ -32,7 +41,14 @@ export const validatePipeline = ({ validation.errors.forEach((error) => acc.jobsErrors[j.name].add(error)) } - acc.areJobsValid = acc.areJobsValid && validation.valid + if (!jobNameValidation.valid) { + jobNameValidation.errors.forEach((error) => + acc.jobsErrors[j.name].add(error), + ) + } + + acc.areJobsValid = + acc.areJobsValid && validation.valid && jobNameValidation.valid return acc }, { areJobsValid: true, jobsErrors: {} }, diff --git a/redisinsight/ui/src/components/yaml-validator/validateYamlSchema.test.ts b/redisinsight/ui/src/components/yaml-validator/validateYamlSchema.test.ts index e17491afe6..33fb9d34da 100644 --- a/redisinsight/ui/src/components/yaml-validator/validateYamlSchema.test.ts +++ b/redisinsight/ui/src/components/yaml-validator/validateYamlSchema.test.ts @@ -1,5 +1,5 @@ import yaml from 'js-yaml' -import { validateYamlSchema } from './validateYamlSchema' +import { validateYamlSchema, validateSchema } from './validateYamlSchema' const schema = { type: 'object', @@ -97,3 +97,168 @@ describe('validateYamlSchema', () => { jest.restoreAllMocks() }) }) + +describe('validateSchema with ValidationConfig', () => { + const testSchema = { + type: 'object', + properties: { + name: { type: 'string' }, + nested: { + type: 'object', + properties: { + value: { type: 'number' } + }, + required: ['value'] + } + }, + required: ['name'] + } + + const invalidData = { + nested: { + value: 'not-a-number' + } + // missing required 'name' field + } + + describe('default ValidationConfig', () => { + it('should use default error message prefix "Error:"', () => { + const result = validateSchema(invalidData, testSchema) + + expect(result.valid).toBe(false) + expect(result.errors).toEqual( + expect.arrayContaining([ + expect.stringContaining('Error:') + ]) + ) + }) + + it('should include path information by default', () => { + const result = validateSchema(invalidData, testSchema) + + expect(result.valid).toBe(false) + expect(result.errors).toEqual( + expect.arrayContaining([ + expect.stringContaining('(at root)'), + expect.stringContaining('(at /nested/value)') + ]) + ) + }) + }) + + describe('custom ValidationConfig', () => { + it('should use custom error message prefix', () => { + const config = { errorMessagePrefix: 'Custom Prefix:' } + const result = validateSchema(invalidData, testSchema, config) + + expect(result.valid).toBe(false) + expect(result.errors).toEqual( + expect.arrayContaining([ + expect.stringContaining('Custom Prefix:') + ]) + ) + expect(result.errors).not.toEqual( + expect.arrayContaining([ + expect.stringContaining('Error:') + ]) + ) + }) + + it('should exclude path information when includePathIntoErrorMessage is false', () => { + const config = { includePathIntoErrorMessage: false } + const result = validateSchema(invalidData, testSchema, config) + + expect(result.valid).toBe(false) + expect(result.errors).not.toEqual( + expect.arrayContaining([ + expect.stringContaining('(at ') + ]) + ) + }) + + it('should use both custom prefix and exclude path information', () => { + const config = { + errorMessagePrefix: 'Custom Error:', + includePathIntoErrorMessage: false + } + const result = validateSchema(invalidData, testSchema, config) + + expect(result.valid).toBe(false) + expect(result.errors).toEqual( + expect.arrayContaining([ + expect.stringContaining('Custom Error:') + ]) + ) + expect(result.errors).not.toEqual( + expect.arrayContaining([ + expect.stringContaining('(at ') + ]) + ) + }) + + it('should handle empty string as error message prefix', () => { + const config = { errorMessagePrefix: '' } + const result = validateSchema(invalidData, testSchema, config) + + expect(result.valid).toBe(false) + expect(result.errors.length).toBeGreaterThan(0) + // Should not start with "Error:" but with the actual error message + expect(result.errors[0]).not.toMatch(/^Error:/) + }) + }) + + describe('ValidationConfig with exceptions', () => { + it('should use custom error prefix for unknown errors', () => { + const mockSchema = null // This will cause an error in AJV + const config = { errorMessagePrefix: 'Schema Error:' } + + const result = validateSchema({}, mockSchema, config) + + expect(result.valid).toBe(false) + expect(result.errors).toEqual(['Schema Error: unknown error']) + }) + + it('should use default error prefix for unknown errors when no config provided', () => { + const mockSchema = null // This will cause an error in AJV + + const result = validateSchema({}, mockSchema) + + expect(result.valid).toBe(false) + expect(result.errors).toEqual(['Error: unknown error']) + }) + }) + + describe('edge cases', () => { + it('should handle valid data with custom config', () => { + const validData = { name: 'test', nested: { value: 42 } } + const config = { + errorMessagePrefix: 'Custom Error:', + includePathIntoErrorMessage: false + } + + const result = validateSchema(validData, testSchema, config) + + expect(result).toEqual({ + valid: true, + errors: [] + }) + }) + + it('should handle undefined config properties gracefully', () => { + const config = { + errorMessagePrefix: undefined, + includePathIntoErrorMessage: undefined + } + const result = validateSchema(invalidData, testSchema, config) + + expect(result.valid).toBe(false) + // Should use defaults when undefined + expect(result.errors).toEqual( + expect.arrayContaining([ + expect.stringContaining('Error:'), + expect.stringContaining('(at ') + ]) + ) + }) + }) +}) diff --git a/redisinsight/ui/src/components/yaml-validator/validateYamlSchema.ts b/redisinsight/ui/src/components/yaml-validator/validateYamlSchema.ts index 6a1463fcc1..363acacaeb 100644 --- a/redisinsight/ui/src/components/yaml-validator/validateYamlSchema.ts +++ b/redisinsight/ui/src/components/yaml-validator/validateYamlSchema.ts @@ -1,12 +1,20 @@ import yaml, { YAMLException } from 'js-yaml' import Ajv from 'ajv' -export const validateYamlSchema = ( - content: string, +type ValidationConfig = { + errorMessagePrefix?: string + includePathIntoErrorMessage?: boolean +} + +export const validateSchema = ( + parsed: any, schema: any, + config: ValidationConfig = {}, ): { valid: boolean; errors: string[] } => { + const errorMessagePrefix = config.errorMessagePrefix ?? 'Error:' + const includePathIntoErrorMessage = config.includePathIntoErrorMessage ?? true + try { - const parsed = yaml.load(content) const ajv = new Ajv({ strict: false, unicodeRegExp: false, @@ -18,12 +26,28 @@ export const validateYamlSchema = ( if (!valid) { const errors = validate.errors?.map( - (err) => `Error: ${err.message} (at ${err.instancePath || 'root'})`, + (err) => { + const pathMessage = includePathIntoErrorMessage ? ` (at ${err.instancePath || 'root'})` : '' + return `${[errorMessagePrefix]} ${err.message}${pathMessage}` + } ) return { valid: false, errors: errors || [] } } return { valid: true, errors: [] } + } catch (e) { + return { valid: false, errors: [`${errorMessagePrefix} unknown error`] } + } +} + +export const validateYamlSchema = ( + content: string, + schema: any, +): { valid: boolean; errors: string[] } => { + try { + const parsed = yaml.load(content) as object + + return validateSchema(parsed, schema) } catch (e) { if (e instanceof YAMLException) { return { valid: false, errors: [`Error: ${e.reason}`] } diff --git a/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/deploy-pipeline-button/DeployPipelineButton.spec.tsx b/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/deploy-pipeline-button/DeployPipelineButton.spec.tsx index f215bea4c3..9646bf7c65 100644 --- a/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/deploy-pipeline-button/DeployPipelineButton.spec.tsx +++ b/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/deploy-pipeline-button/DeployPipelineButton.spec.tsx @@ -8,6 +8,7 @@ import { render, screen, } from 'uiSrc/utils/test-utils' +import { rdiPipelineSelector } from 'uiSrc/slices/rdi/pipeline' import DeployPipelineButton, { Props } from './DeployPipelineButton' const mockedProps: Props = { @@ -25,6 +26,7 @@ jest.mock('uiSrc/slices/rdi/pipeline', () => ({ rdiPipelineSelector: jest.fn().mockReturnValue({ loading: false, config: 'value', + isPipelineValid: true, jobs: [ { name: 'job1', value: '1' }, { name: 'job2', value: '2' }, @@ -88,7 +90,7 @@ describe('DeployPipelineButton', () => { }) }) - it('should open confirmation popover', () => { + it('should open confirmation popover with default message', () => { render() expect(screen.queryByTestId('deploy-confirm-btn')).not.toBeInTheDocument() @@ -96,5 +98,35 @@ describe('DeployPipelineButton', () => { fireEvent.click(screen.getByTestId('deploy-rdi-pipeline')) expect(screen.queryByTestId('deploy-confirm-btn')).toBeInTheDocument() + expect( + screen.queryByText('Are you sure you want to deploy the pipeline?'), + ).toBeInTheDocument() + expect( + screen.queryByText( + 'Your RDI pipeline contains errors. Are you sure you want to continue?', + ), + ).not.toBeInTheDocument() + }) + + it('should open confirmation popover with warning message due to validation errors', () => { + ;(rdiPipelineSelector as jest.Mock).mockImplementation(() => ({ + isPipelineValid: false, + })) + + render() + + expect(screen.queryByTestId('deploy-confirm-btn')).not.toBeInTheDocument() + + fireEvent.click(screen.getByTestId('deploy-rdi-pipeline')) + + expect(screen.queryByTestId('deploy-confirm-btn')).toBeInTheDocument() + expect( + screen.queryByText('Are you sure you want to deploy the pipeline?'), + ).not.toBeInTheDocument() + expect( + screen.queryByText( + 'Your RDI pipeline contains errors. Are you sure you want to continue?', + ), + ).toBeInTheDocument() }) }) diff --git a/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/deploy-pipeline-button/DeployPipelineButton.tsx b/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/deploy-pipeline-button/DeployPipelineButton.tsx index e245c2afaa..31a7b20ab4 100644 --- a/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/deploy-pipeline-button/DeployPipelineButton.tsx +++ b/redisinsight/ui/src/pages/rdi/instance/components/header/components/buttons/deploy-pipeline-button/DeployPipelineButton.tsx @@ -14,7 +14,7 @@ import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import { createAxiosError, pipelineToJson } from 'uiSrc/utils' import { addErrorNotification } from 'uiSrc/slices/app/notifications' import { rdiErrorMessages } from 'uiSrc/pages/rdi/constants' -import { Text } from 'uiSrc/components/base/text' +import { ColorText, Text } from 'uiSrc/components/base/text' import { FlexItem, Row } from 'uiSrc/components/base/layout/flex' import { Spacer } from 'uiSrc/components/base/layout/spacer' import { OutsideClickDetector } from 'uiSrc/components/base/utils' @@ -36,7 +36,8 @@ const DeployPipelineButton = ({ loading, disabled, onReset }: Props) => { const [isPopoverOpen, setIsPopoverOpen] = useState(false) const [resetPipeline, setResetPipeline] = useState(false) - const { config, jobs, resetChecked } = useSelector(rdiPipelineSelector) + const { config, jobs, resetChecked, isPipelineValid } = + useSelector(rdiPipelineSelector) const { rdiInstanceId } = useParams<{ rdiInstanceId: string }>() const dispatch = useDispatch() @@ -127,7 +128,19 @@ const DeployPipelineButton = ({ loading, disabled, onReset }: Props) => { } > - Are you sure you want to deploy the pipeline? + + {isPipelineValid ? ( + <ColorText color="default"> + Are you sure you want to deploy the pipeline? + </ColorText> + ) : ( + <ColorText color="warning"> + <RiIcon type="ToastDangerIcon" size="L" color="attention500" /> + Your RDI pipeline contains errors. Are you sure you want to + continue? + </ColorText> + )} + When deployed, this local configuration will overwrite any existing diff --git a/redisinsight/ui/src/pages/rdi/instance/components/header/components/pipeline-actions/PipelineActions.spec.tsx b/redisinsight/ui/src/pages/rdi/instance/components/header/components/pipeline-actions/PipelineActions.spec.tsx index 2bf13b71c1..c197c68c76 100644 --- a/redisinsight/ui/src/pages/rdi/instance/components/header/components/pipeline-actions/PipelineActions.spec.tsx +++ b/redisinsight/ui/src/pages/rdi/instance/components/header/components/pipeline-actions/PipelineActions.spec.tsx @@ -103,6 +103,8 @@ describe('PipelineActions', () => { ;(rdiPipelineSelector as jest.Mock).mockReturnValueOnce({ loading: false, schema: 'test-schema', + monacoJobsSchema: 'test-monaco-jobs-schema', + jobNameSchema: 'test-job-name-schema', config: 'test-config', jobs: 'test-jobs', }) @@ -111,6 +113,8 @@ describe('PipelineActions', () => { expect(validatePipeline).toHaveBeenCalledWith({ schema: 'test-schema', + monacoJobsSchema: 'test-monaco-jobs-schema', + jobNameSchema: 'test-job-name-schema', config: 'test-config', jobs: 'test-jobs', }) @@ -168,7 +172,7 @@ describe('PipelineActions', () => { ) }) - it('should dispatch validation errors if validation fails', () => { + it('should dispatch validation errors if validation fails but still deploy button should be enabled', () => { ;(validatePipeline as jest.Mock).mockReturnValue({ result: false, configValidationErrors: ['Missing field'], @@ -177,6 +181,8 @@ describe('PipelineActions', () => { ;(rdiPipelineSelector as jest.Mock).mockReturnValueOnce({ loading: false, schema: 'test-schema', + monacoJobsSchema: 'test-monaco-jobs-schema', + jobNameSchema: 'test-job-name-schema', config: 'test-config', jobs: 'test-jobs', }) @@ -199,6 +205,97 @@ describe('PipelineActions', () => { }), ]), ) + + expect(screen.queryByTestId('deploy-rdi-pipeline')).not.toBeDisabled() + }) + + describe('validation with new schema parameters', () => { + it('should pass monacoJobsSchema and jobNameSchema to validatePipeline when available', () => { + const mockMonacoJobsSchema = { type: 'object', properties: { task: { type: 'string' } } } + const mockJobNameSchema = { type: 'string', pattern: '^[a-zA-Z][a-zA-Z0-9_]*$' } + + ;(validatePipeline as jest.Mock).mockReturnValue({ + result: true, + configValidationErrors: [], + jobsValidationErrors: {}, + }) + ;(rdiPipelineSelector as jest.Mock).mockReturnValueOnce({ + loading: false, + schema: 'test-schema', + monacoJobsSchema: mockMonacoJobsSchema, + jobNameSchema: mockJobNameSchema, + config: 'test-config', + jobs: 'test-jobs', + }) + + render() + + expect(validatePipeline).toHaveBeenCalledWith({ + schema: 'test-schema', + monacoJobsSchema: mockMonacoJobsSchema, + jobNameSchema: mockJobNameSchema, + config: 'test-config', + jobs: 'test-jobs', + }) + }) + + it('should pass null/undefined schemas to validatePipeline when not available', () => { + ;(validatePipeline as jest.Mock).mockReturnValue({ + result: true, + configValidationErrors: [], + jobsValidationErrors: {}, + }) + ;(rdiPipelineSelector as jest.Mock).mockReturnValueOnce({ + loading: false, + schema: 'test-schema', + monacoJobsSchema: null, + jobNameSchema: undefined, + config: 'test-config', + jobs: 'test-jobs', + }) + + render() + + expect(validatePipeline).toHaveBeenCalledWith({ + schema: 'test-schema', + monacoJobsSchema: null, + jobNameSchema: undefined, + config: 'test-config', + jobs: 'test-jobs', + }) + }) + + it('should include monacoJobsSchema and jobNameSchema in dependency array for validation effect', () => { + // This test verifies that the useEffect dependency array includes the new schema parameters + // by checking that different schema values trigger different validatePipeline calls + + ;(validatePipeline as jest.Mock).mockReturnValue({ + result: true, + configValidationErrors: [], + jobsValidationErrors: {}, + }) + + // First render with specific schemas + ;(rdiPipelineSelector as jest.Mock).mockReturnValueOnce({ + loading: false, + schema: 'test-schema', + monacoJobsSchema: { type: 'object', properties: { task: { type: 'string' } } }, + jobNameSchema: { type: 'string', pattern: '^[a-zA-Z]+$' }, + config: 'test-config', + jobs: 'test-jobs', + }) + + render() + + // Verify that validatePipeline was called with all the correct parameters including schemas + expect(validatePipeline).toHaveBeenCalledWith({ + schema: 'test-schema', + monacoJobsSchema: { type: 'object', properties: { task: { type: 'string' } } }, + jobNameSchema: { type: 'string', pattern: '^[a-zA-Z]+$' }, + config: 'test-config', + jobs: 'test-jobs', + }) + }) }) describe('TelemetryEvent', () => { diff --git a/redisinsight/ui/src/pages/rdi/instance/components/header/components/pipeline-actions/PipelineActions.tsx b/redisinsight/ui/src/pages/rdi/instance/components/header/components/pipeline-actions/PipelineActions.tsx index aac39eea7e..b4cf73b8df 100644 --- a/redisinsight/ui/src/pages/rdi/instance/components/header/components/pipeline-actions/PipelineActions.tsx +++ b/redisinsight/ui/src/pages/rdi/instance/components/header/components/pipeline-actions/PipelineActions.tsx @@ -22,7 +22,6 @@ import { PipelineStatus, } from 'uiSrc/slices/interfaces' -import { RiTooltip } from 'uiSrc/components' import { FlexItem, Row } from 'uiSrc/components/base/layout/flex' import DeployPipelineButton from '../buttons/deploy-pipeline-button' import ResetPipelineButton from '../buttons/reset-pipeline-button' @@ -38,8 +37,9 @@ export interface Props { const PipelineActions = ({ collectorStatus, pipelineStatus }: Props) => { const { loading: deployLoading, - isPipelineValid, schema, + monacoJobsSchema, + jobNameSchema, config, jobs, } = useSelector(rdiPipelineSelector) @@ -58,7 +58,13 @@ const PipelineActions = ({ collectorStatus, pipelineStatus }: Props) => { } const { result, configValidationErrors, jobsValidationErrors } = - validatePipeline({ schema, config, jobs }) + validatePipeline({ + schema, + monacoJobsSchema, + jobNameSchema, + config, + jobs, + }) dispatch(setConfigValidationErrors(configValidationErrors)) dispatch(setJobsValidationErrors(jobsValidationErrors)) @@ -141,7 +147,6 @@ const PipelineActions = ({ collectorStatus, pipelineStatus }: Props) => { const isLoadingBtn = (actionBtn: PipelineAction) => action === actionBtn && actionLoading const disabled = deployLoading || actionLoading - const isDeployButtonDisabled = disabled || !isPipelineValid return ( @@ -168,21 +173,11 @@ const PipelineActions = ({ collectorStatus, pipelineStatus }: Props) => { )} - - - + diff --git a/redisinsight/ui/src/pages/rdi/pipeline-management/components/jobs-tree/JobsTree.spec.tsx b/redisinsight/ui/src/pages/rdi/pipeline-management/components/jobs-tree/JobsTree.spec.tsx index bdda5f28e1..3cd173cbe5 100644 --- a/redisinsight/ui/src/pages/rdi/pipeline-management/components/jobs-tree/JobsTree.spec.tsx +++ b/redisinsight/ui/src/pages/rdi/pipeline-management/components/jobs-tree/JobsTree.spec.tsx @@ -289,4 +289,153 @@ describe('JobsTree', () => { expect(screen.getByTestId('apply-btn')).toBeDisabled() }) + + it('should display ValidationErrorsList in tooltip when job has multiple validation errors', () => { + const validationErrors = [ + 'Missing required field: name', + 'Invalid data type for age', + 'Email format is incorrect' + ] + + ;(rdiPipelineSelector as jest.Mock).mockImplementationOnce(() => ({ + loading: false, + error: '', + jobs: [{ name: 'job1', value: 'value' }], + jobsValidationErrors: { + job1: validationErrors, + }, + })) + + render() + + expect(screen.getByTestId('rdi-nav-job-job1')).toBeInTheDocument() + expect(screen.getByTestId('rdi-pipeline-nav__error')).toBeInTheDocument() + + // The ValidationErrorsList is inside a tooltip, so we verify the error icon is present + const errorIcon = screen.getByTestId('rdi-pipeline-nav__error') + expect(errorIcon).toBeInTheDocument() + }) + + it('should display ValidationErrorsList in tooltip when job has single validation error', () => { + const validationErrors = ['Single validation error'] + + ;(rdiPipelineSelector as jest.Mock).mockImplementationOnce(() => ({ + loading: false, + error: '', + jobs: [{ name: 'job1', value: 'value' }], + jobsValidationErrors: { + job1: validationErrors, + }, + })) + + render() + + expect(screen.getByTestId('rdi-nav-job-job1')).toBeInTheDocument() + expect(screen.getByTestId('rdi-pipeline-nav__error')).toBeInTheDocument() + }) + + it('should handle multiple jobs with different validation states', () => { + ;(rdiPipelineSelector as jest.Mock).mockImplementationOnce(() => ({ + loading: false, + error: '', + jobs: [ + { name: 'job1', value: 'value1' }, + { name: 'job2', value: 'value2' }, + { name: 'job3', value: 'value3' } + ], + jobsValidationErrors: { + job1: ['Error in job1'], + job3: ['Error in job3', 'Another error in job3'], + }, + })) + + render() + + // job1 should have error icon + const job1Element = screen.getByTestId('rdi-nav-job-job1') + expect(job1Element).toBeInTheDocument() + expect(job1Element).toHaveClass('invalid') + + // job2 should not have error icon and should not have invalid class + const job2Element = screen.getByTestId('rdi-nav-job-job2') + expect(job2Element).toBeInTheDocument() + expect(job2Element).not.toHaveClass('invalid') + + // job3 should have error icon + const job3Element = screen.getByTestId('rdi-nav-job-job3') + expect(job3Element).toBeInTheDocument() + expect(job3Element).toHaveClass('invalid') + + // There should be exactly 2 error icons total (for job1 and job3) + const errorIcons = screen.getAllByTestId('rdi-pipeline-nav__error') + expect(errorIcons).toHaveLength(2) + }) + + it('should apply invalid class to job name when validation errors exist', () => { + ;(rdiPipelineSelector as jest.Mock).mockImplementationOnce(() => ({ + loading: false, + error: '', + jobs: [{ name: 'job1', value: 'value' }], + jobsValidationErrors: { + job1: ['Some validation error'], + }, + })) + + render() + + expect(screen.getByTestId('rdi-nav-job-job1')).toHaveClass('invalid') + }) + + it('should not apply invalid class to job name when no validation errors exist', () => { + ;(rdiPipelineSelector as jest.Mock).mockImplementationOnce(() => ({ + loading: false, + error: '', + jobs: [{ name: 'job1', value: 'value' }], + jobsValidationErrors: {}, + })) + + render() + + expect(screen.getByTestId('rdi-nav-job-job1')).not.toHaveClass('invalid') + }) + + it('should handle empty validation errors array', () => { + ;(rdiPipelineSelector as jest.Mock).mockImplementationOnce(() => ({ + loading: false, + error: '', + jobs: [{ name: 'job1', value: 'value' }], + jobsValidationErrors: { + job1: [], + }, + })) + + render() + + expect(screen.getByTestId('rdi-nav-job-job1')).toBeInTheDocument() + expect( + screen.queryByTestId('rdi-pipeline-nav__error'), + ).not.toBeInTheDocument() + }) + + it('should handle validation errors with special characters', () => { + const validationErrors = [ + 'Error with ', + 'Error with & special characters', + 'Error with "quotes" and \'apostrophes\'' + ] + + ;(rdiPipelineSelector as jest.Mock).mockImplementationOnce(() => ({ + loading: false, + error: '', + jobs: [{ name: 'job1', value: 'value' }], + jobsValidationErrors: { + job1: validationErrors, + }, + })) + + render() + + expect(screen.getByTestId('rdi-nav-job-job1')).toBeInTheDocument() + expect(screen.getByTestId('rdi-pipeline-nav__error')).toBeInTheDocument() + }) }) diff --git a/redisinsight/ui/src/pages/rdi/pipeline-management/components/jobs-tree/JobsTree.tsx b/redisinsight/ui/src/pages/rdi/pipeline-management/components/jobs-tree/JobsTree.tsx index d94c78952f..351e975323 100644 --- a/redisinsight/ui/src/pages/rdi/pipeline-management/components/jobs-tree/JobsTree.tsx +++ b/redisinsight/ui/src/pages/rdi/pipeline-management/components/jobs-tree/JobsTree.tsx @@ -1,6 +1,4 @@ -import { - EuiAccordion, -} from '@elastic/eui' +import { EuiAccordion } from '@elastic/eui' import cx from 'classnames' import React, { useState } from 'react' import { useDispatch, useSelector } from 'react-redux' @@ -29,6 +27,7 @@ import { } from 'uiSrc/components/base/forms/buttons' import { RiIcon } from 'uiSrc/components/base/icons/RiIcon' import { Loader } from 'uiSrc/components/base/display' +import ValidationErrorsList from 'uiSrc/pages/rdi/pipeline-management/components/validation-errors-list/ValidationErrorsList' import styles from './styles.module.scss' export interface IProps { @@ -160,7 +159,11 @@ const JobsTree = (props: IProps) => { const handleToggleAccordion = (isOpen: boolean) => setAccordionState(isOpen ? 'open' : 'closed') - const jobName = (name: string, isValid: boolean = true) => ( + const jobName = ( + name: string, + isValid: boolean = true, + validationErrors: string[] = [], + ) => ( <> { {name} {!isValid && ( - + + } + > + + )} { ? jobsValidationErrors[jobName].length === 0 : true + const getJobValidionErrors = (jobName: string) => + jobsValidationErrors[jobName] || [] + const renderJobsList = (jobs: IRdiPipelineJob[]) => jobs.map(({ name }, idx) => ( { {currentJobName === name ? jobNameEditor(name, idx) - : jobName(name, isJobValid(name))} + : jobName(name, isJobValid(name), getJobValidionErrors(name))} )) diff --git a/redisinsight/ui/src/pages/rdi/pipeline-management/components/jobs-tree/styles.module.scss b/redisinsight/ui/src/pages/rdi/pipeline-management/components/jobs-tree/styles.module.scss index 5cc298318b..5d771db0e5 100644 --- a/redisinsight/ui/src/pages/rdi/pipeline-management/components/jobs-tree/styles.module.scss +++ b/redisinsight/ui/src/pages/rdi/pipeline-management/components/jobs-tree/styles.module.scss @@ -2,6 +2,7 @@ .navItem { cursor: pointer; white-space: pre !important; + flex-direction: row !important; &:hover { text-decoration: underline; diff --git a/redisinsight/ui/src/pages/rdi/pipeline-management/components/navigation/Navigation.tsx b/redisinsight/ui/src/pages/rdi/pipeline-management/components/navigation/Navigation.tsx index e5d62944e6..6801f4af10 100644 --- a/redisinsight/ui/src/pages/rdi/pipeline-management/components/navigation/Navigation.tsx +++ b/redisinsight/ui/src/pages/rdi/pipeline-management/components/navigation/Navigation.tsx @@ -71,6 +71,7 @@ const Navigation = () => { data-testid={`rdi-pipeline-tab-${RdiPipelineTabs.Config}`} isLoading={loading} isValid={configValidationErrors.length === 0} + validationErrors={configValidationErrors} >
{!!changes.config && ( diff --git a/redisinsight/ui/src/pages/rdi/pipeline-management/components/tab/Tab.spec.tsx b/redisinsight/ui/src/pages/rdi/pipeline-management/components/tab/Tab.spec.tsx index 7f6b9e039c..271c8c4627 100644 --- a/redisinsight/ui/src/pages/rdi/pipeline-management/components/tab/Tab.spec.tsx +++ b/redisinsight/ui/src/pages/rdi/pipeline-management/components/tab/Tab.spec.tsx @@ -92,4 +92,84 @@ describe('Tab', () => { expect(screen.getByTestId('tab-child')).toBeInTheDocument() }) + + it('should display validation errors in tooltip when isValid is false and validationErrors are provided', () => { + const validationErrors = [ + 'Missing required field: name', + 'Invalid data type for age' + ] + + render( + + ) + + expect(screen.getByTestId('rdi-nav-config-error')).toBeInTheDocument() + + const errorIcon = screen.getByTestId('rdi-nav-config-error') + expect(errorIcon).toBeInTheDocument() + }) + + it('should not display validation errors when isValid is true even if validationErrors are provided', () => { + const validationErrors = [ + 'Some validation error' + ] + + render( + + ) + + expect(screen.queryByTestId('rdi-nav-config-error')).not.toBeInTheDocument() + }) + + it('should display error icon even when validationErrors is empty but isValid is false', () => { + render( + + ) + + expect(screen.getByTestId('rdi-nav-config-error')).toBeInTheDocument() + }) + + it('should handle validationErrors prop correctly when not provided', () => { + render( + + ) + + expect(screen.getByTestId('rdi-nav-config-error')).toBeInTheDocument() + }) + + it('should not show error icon when fileName is not provided even if isValid is false', () => { + render( + + ) + + expect(screen.queryByTestId('rdi-nav-config-error')).not.toBeInTheDocument() + }) }) diff --git a/redisinsight/ui/src/pages/rdi/pipeline-management/components/tab/Tab.tsx b/redisinsight/ui/src/pages/rdi/pipeline-management/components/tab/Tab.tsx index 1ba2a9a990..975f2f8a70 100644 --- a/redisinsight/ui/src/pages/rdi/pipeline-management/components/tab/Tab.tsx +++ b/redisinsight/ui/src/pages/rdi/pipeline-management/components/tab/Tab.tsx @@ -4,6 +4,8 @@ import { Text } from 'uiSrc/components/base/text' import { Loader } from 'uiSrc/components/base/display' import { RiIcon } from 'uiSrc/components/base/icons/RiIcon' +import { RiTooltip } from 'uiSrc/components' +import ValidationErrorsList from 'uiSrc/pages/rdi/pipeline-management/components/validation-errors-list/ValidationErrorsList' import styles from './styles.module.scss' export interface IProps { @@ -15,6 +17,7 @@ export interface IProps { testID?: string isLoading?: boolean isValid?: boolean + validationErrors?: string[] } const Tab = (props: IProps) => { @@ -27,6 +30,7 @@ const Tab = (props: IProps) => { className, isLoading = false, isValid = true, + validationErrors = [], } = props return ( @@ -45,12 +49,19 @@ const Tab = (props: IProps) => { {!isValid && ( - + + } + > + + )} {isLoading && ( diff --git a/redisinsight/ui/src/pages/rdi/pipeline-management/components/validation-errors-list/ValidationErrorsList.spec.tsx b/redisinsight/ui/src/pages/rdi/pipeline-management/components/validation-errors-list/ValidationErrorsList.spec.tsx new file mode 100644 index 0000000000..56844c4c60 --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/pipeline-management/components/validation-errors-list/ValidationErrorsList.spec.tsx @@ -0,0 +1,94 @@ +import React from 'react' +import { render, screen } from 'uiSrc/utils/test-utils' + +import ValidationErrorsList, { Props } from './ValidationErrorsList' + +describe('ValidationErrorsList', () => { + it('should render', () => { + const props: Props = { + validationErrors: [] + } + expect(render()).toBeTruthy() + }) + + it('should not render anything when validationErrors is undefined', () => { + const props: Props = { + validationErrors: undefined as any + } + const { container } = render() + + expect(container.firstChild).toBeNull() + }) + + it('should render a single validation error', () => { + const props: Props = { + validationErrors: ['Invalid configuration format'] + } + render() + + expect(screen.getByText('Invalid configuration format')).toBeInTheDocument() + expect(screen.getByRole('list')).toBeInTheDocument() + expect(screen.getAllByRole('listitem')).toHaveLength(1) + }) + + it('should render multiple validation errors', () => { + const props: Props = { + validationErrors: [ + 'Missing required field: name', + 'Invalid data type for age', + 'Email format is incorrect' + ] + } + render() + + expect(screen.getByText('Missing required field: name')).toBeInTheDocument() + expect(screen.getByText('Invalid data type for age')).toBeInTheDocument() + expect(screen.getByText('Email format is incorrect')).toBeInTheDocument() + expect(screen.getByRole('list')).toBeInTheDocument() + expect(screen.getAllByRole('listitem')).toHaveLength(3) + }) + + it('should render validation errors as list items within a Text component', () => { + const props: Props = { + validationErrors: ['Error message 1', 'Error message 2'] + } + render() + + const list = screen.getByRole('list') + expect(list.tagName).toBe('UL') + expect(list.parentElement?.tagName).toBe('DIV') + + const listItems = screen.getAllByRole('listitem') + expect(listItems).toHaveLength(2) + expect(listItems[0]).toHaveTextContent('Error message 1') + expect(listItems[1]).toHaveTextContent('Error message 2') + }) + + it('should handle special characters and HTML in error messages', () => { + const props: Props = { + validationErrors: [ + 'Error with ', + 'Error with & special characters', + 'Error with "quotes" and \'apostrophes\'' + ] + } + render() + + expect(screen.getByText('Error with ')).toBeInTheDocument() + expect(screen.getByText('Error with & special characters')).toBeInTheDocument() + expect(screen.getByText('Error with "quotes" and \'apostrophes\'')).toBeInTheDocument() + }) + + it('should handle empty string errors', () => { + const props: Props = { + validationErrors: ['', 'Valid error message', ''] + } + render() + + const listItems = screen.getAllByRole('listitem') + expect(listItems).toHaveLength(3) + expect(listItems[0]).toHaveTextContent('') + expect(listItems[1]).toHaveTextContent('Valid error message') + expect(listItems[2]).toHaveTextContent('') + }) +}) diff --git a/redisinsight/ui/src/pages/rdi/pipeline-management/components/validation-errors-list/ValidationErrorsList.tsx b/redisinsight/ui/src/pages/rdi/pipeline-management/components/validation-errors-list/ValidationErrorsList.tsx new file mode 100644 index 0000000000..e6a2751cea --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/pipeline-management/components/validation-errors-list/ValidationErrorsList.tsx @@ -0,0 +1,24 @@ +import React from 'react' + +export interface Props { + validationErrors: string[] +} + +const ValidationErrorsList = (props: Props) => { + const { validationErrors } = props + + if(!validationErrors?.length) { + return null + } + + return ( +
    + {validationErrors.map((err, index) => ( + // eslint-disable-next-line react/no-array-index-key +
  • {err}
  • + ))} +
+ ) +} + +export default ValidationErrorsList diff --git a/redisinsight/ui/src/pages/rdi/pipeline-management/pages/job/Job.spec.tsx b/redisinsight/ui/src/pages/rdi/pipeline-management/pages/job/Job.spec.tsx index 9ceab54255..9645ee08b8 100644 --- a/redisinsight/ui/src/pages/rdi/pipeline-management/pages/job/Job.spec.tsx +++ b/redisinsight/ui/src/pages/rdi/pipeline-management/pages/job/Job.spec.tsx @@ -33,7 +33,7 @@ jest.mock('uiSrc/slices/rdi/pipeline', () => ({ ...jest.requireActual('uiSrc/slices/rdi/pipeline'), rdiPipelineSelector: jest.fn().mockReturnValue({ loading: false, - schema: { jobs: { test: {} } }, + monacoJobsSchema: { jobs: { test: {} } }, config: `connections: target: type: redis @@ -72,7 +72,7 @@ describe('Job', () => { it('should not push to config page', () => { const rdiPipelineSelectorMock = jest.fn().mockReturnValue({ loading: false, - schema: { jobs: { test: {} } }, + monacoJobsSchema: { jobs: { test: {} } }, error: '', config: `connections: target: @@ -258,6 +258,7 @@ describe('Job', () => { it('should render loading spinner', () => { const rdiPipelineSelectorMock = jest.fn().mockReturnValue({ loading: true, + monacoJobsSchema: { jobs: { test: {} } }, }) ;(rdiPipelineSelector as jest.Mock).mockImplementation( rdiPipelineSelectorMock, @@ -267,4 +268,144 @@ describe('Job', () => { expect(screen.getByTestId('rdi-job-loading')).toBeInTheDocument() }) + + describe('monacoJobsSchema integration', () => { + it('should pass monacoJobsSchema to MonacoYaml when available', () => { + const mockMonacoJobsSchema = { + type: 'object', + properties: { + source: { type: 'object' }, + transform: { type: 'object' }, + output: { type: 'object' } + } + } + + const rdiPipelineSelectorMock = jest.fn().mockReturnValue({ + loading: false, + schema: { jobs: { test: {} } }, + monacoJobsSchema: mockMonacoJobsSchema, + config: 'test-config', + jobs: [ + { + name: 'testJob', + value: 'test-value' + } + ], + }) + ;(rdiPipelineSelector as jest.Mock).mockImplementation( + rdiPipelineSelectorMock, + ) + + render() + + // Verify the component renders and doesn't crash with schema + expect(screen.getByTestId('rdi-monaco-job')).toBeInTheDocument() + expect(screen.queryByTestId('rdi-job-loading')).not.toBeInTheDocument() + }) + + it('should handle empty monacoJobsSchema gracefully', () => { + const rdiPipelineSelectorMock = jest.fn().mockReturnValue({ + loading: false, + monacoJobsSchema: {}, + config: 'test-config', + jobs: [ + { + name: 'testJob', + value: 'test-value' + } + ], + }) + ;(rdiPipelineSelector as jest.Mock).mockImplementation( + rdiPipelineSelectorMock, + ) + + render() + + // Verify the component renders without issues when schema is empty + expect(screen.getByTestId('rdi-monaco-job')).toBeInTheDocument() + expect(screen.queryByTestId('rdi-job-loading')).not.toBeInTheDocument() + }) + + it('should handle undefined monacoJobsSchema gracefully', () => { + const rdiPipelineSelectorMock = jest.fn().mockReturnValue({ + loading: false, + monacoJobsSchema: undefined, + config: 'test-config', + jobs: [ + { + name: 'testJob', + value: 'test-value' + } + ], + }) + ;(rdiPipelineSelector as jest.Mock).mockImplementation( + rdiPipelineSelectorMock, + ) + + render() + + // Verify the component renders without issues when schema is undefined + expect(screen.getByTestId('rdi-monaco-job')).toBeInTheDocument() + expect(screen.queryByTestId('rdi-job-loading')).not.toBeInTheDocument() + }) + + it('should pass complex monacoJobsSchema structure to MonacoYaml', () => { + const complexSchema = { + type: 'object', + properties: { + source: { + type: 'object', + properties: { + server_name: { type: 'string' }, + schema: { type: 'string' }, + table: { type: 'string' } + }, + required: ['server_name', 'schema', 'table'] + }, + transform: { + type: 'array', + items: { + type: 'object', + properties: { + uses: { type: 'string' }, + with: { type: 'object' } + } + } + }, + output: { + type: 'array', + items: { + type: 'object', + properties: { + uses: { type: 'string' }, + with: { type: 'object' } + } + } + } + }, + required: ['source'] + } + + const rdiPipelineSelectorMock = jest.fn().mockReturnValue({ + loading: false, + monacoJobsSchema: complexSchema, + config: 'test-config', + jobs: [ + { + name: 'complexJob', + value: 'source:\n server_name: test' + } + ], + }) + ;(rdiPipelineSelector as jest.Mock).mockImplementation( + rdiPipelineSelectorMock, + ) + + render() + + // Verify the component renders with complex schema structure + expect(screen.getByTestId('rdi-monaco-job')).toBeInTheDocument() + expect(screen.queryByTestId('rdi-job-loading')).not.toBeInTheDocument() + }) + }) }) diff --git a/redisinsight/ui/src/pages/rdi/pipeline-management/pages/job/Job.tsx b/redisinsight/ui/src/pages/rdi/pipeline-management/pages/job/Job.tsx index fb9efbce79..d7b2f613e4 100644 --- a/redisinsight/ui/src/pages/rdi/pipeline-management/pages/job/Job.tsx +++ b/redisinsight/ui/src/pages/rdi/pipeline-management/pages/job/Job.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useRef, useCallback } from 'react' import { useDispatch, useSelector } from 'react-redux' -import { get, throttle } from 'lodash' +import { throttle } from 'lodash' import cx from 'classnames' import { monaco as monacoEditor } from 'react-monaco-editor' @@ -59,7 +59,7 @@ const Job = (props: Props) => { const deployedJobValueRef = useRef>(deployedJobValue) const jobNameRef = useRef(name) - const { loading, schema, jobFunctions, jobs } = + const { loading, monacoJobsSchema, jobFunctions, jobs } = useSelector(rdiPipelineSelector) useEffect(() => { @@ -243,7 +243,7 @@ const Job = (props: Props) => {
) : ( resetChecked: boolean schema: Nullable + jobNameSchema: Nullable + monacoJobsSchema: Nullable strategies: IRdiPipelineStrategies changes: Record jobFunctions: monacoEditor.languages.CompletionItem[] diff --git a/redisinsight/ui/src/slices/rdi/pipeline.ts b/redisinsight/ui/src/slices/rdi/pipeline.ts index f09768d45d..a3dcb5b9ef 100644 --- a/redisinsight/ui/src/slices/rdi/pipeline.ts +++ b/redisinsight/ui/src/slices/rdi/pipeline.ts @@ -1,5 +1,6 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit' import { AxiosError } from 'axios' +import { get, omit } from 'lodash' import { apiService } from 'uiSrc/services' import { addErrorNotification, @@ -45,6 +46,8 @@ export const initialState: IStateRdiPipeline = { jobsValidationErrors: {}, resetChecked: false, schema: null, + jobNameSchema: null, + monacoJobsSchema: null, strategies: { loading: false, error: '', @@ -125,6 +128,18 @@ const rdiPipelineSlice = createSlice({ ) => { state.schema = payload }, + setMonacoJobsSchema: ( + state, + { payload }: PayloadAction>, + ) => { + state.monacoJobsSchema = payload + }, + setJobNameSchema: ( + state, + { payload }: PayloadAction>, + ) => { + state.jobNameSchema = payload + }, getPipelineStrategies: (state) => { state.strategies.loading = true }, @@ -221,6 +236,8 @@ export const { deployPipelineSuccess, deployPipelineFailure, setPipelineSchema, + setMonacoJobsSchema, + setJobNameSchema, getPipelineStrategies, getPipelineStrategiesSuccess, getPipelineStrategiesFailure, @@ -395,12 +412,19 @@ export function fetchRdiPipelineSchema( ) { return async (dispatch: AppDispatch) => { try { - const { data, status } = await apiService.get( + const { data, status } = await apiService.get>( getRdiUrl(rdiInstanceId, ApiEndpoints.RDI_PIPELINE_SCHEMA), ) if (isStatusSuccessful(status)) { dispatch(setPipelineSchema(data)) + dispatch(setMonacoJobsSchema({ + ...omit(get(data, ['jobs'], {}), ['properties.name']), + required: get(data, ['jobs', 'required'], []).filter( + (val: string) => val !== 'name', + ), + })) + dispatch(setJobNameSchema(get(data, ['jobs', 'properties', 'name'], null))) onSuccessAction?.(data) } } catch (_err) { diff --git a/redisinsight/ui/src/slices/tests/rdi/pipeline.spec.ts b/redisinsight/ui/src/slices/tests/rdi/pipeline.spec.ts index 271b0b6a98..64a2ad02d7 100644 --- a/redisinsight/ui/src/slices/tests/rdi/pipeline.spec.ts +++ b/redisinsight/ui/src/slices/tests/rdi/pipeline.spec.ts @@ -50,6 +50,8 @@ import reducer, { rdiPipelineActionSelector, setPipelineConfig, setPipelineJobs, + setMonacoJobsSchema, + setJobNameSchema, } from 'uiSrc/slices/rdi/pipeline' import { apiService } from 'uiSrc/services' import { @@ -835,7 +837,7 @@ describe('rdi pipe slice', () => { }) describe('fetchRdiPipelineSchema', () => { - it('succeed to fetch data', async () => { + it('succeed to fetch data with minimal schema', async () => { const data = { config: 'string' } const responsePayload = { data, status: 200 } @@ -845,7 +847,130 @@ describe('rdi pipe slice', () => { await store.dispatch(fetchRdiPipelineSchema('123')) // Assert - const expectedActions = [setPipelineSchema(data)] + const expectedActions = [ + setPipelineSchema(data), + setMonacoJobsSchema({ required: [] }), + setJobNameSchema(null), + ] + + expect(store.getActions()).toEqual(expectedActions) + }) + + it('succeed to fetch data with complete jobs schema', async () => { + const data = { + config: 'string', + jobs: { + type: 'object', + properties: { + name: { + type: 'string', + pattern: '^[a-zA-Z][a-zA-Z0-9_]*$' + }, + source: { + type: 'object', + properties: { + server_name: { type: 'string' }, + schema: { type: 'string' }, + table: { type: 'string' } + } + } + }, + required: ['name', 'source'] + } + } + const responsePayload = { data, status: 200 } + + apiService.get = jest.fn().mockResolvedValue(responsePayload) + + // Act + await store.dispatch(fetchRdiPipelineSchema('123')) + + // Assert + const expectedMonacoJobsSchema = { + type: 'object', + properties: { + source: { + type: 'object', + properties: { + server_name: { type: 'string' }, + schema: { type: 'string' }, + table: { type: 'string' } + } + } + }, + required: ['source'] // 'name' is filtered out + } + + const expectedJobNameSchema = { + type: 'string', + pattern: '^[a-zA-Z][a-zA-Z0-9_]*$' + } + + const expectedActions = [ + setPipelineSchema(data), + setMonacoJobsSchema(expectedMonacoJobsSchema), + setJobNameSchema(expectedJobNameSchema), + ] + + expect(store.getActions()).toEqual(expectedActions) + }) + + it('succeed to fetch data with jobs schema but no name property', async () => { + const data = { + config: 'string', + jobs: { + type: 'object', + properties: { + source: { type: 'object' }, + transform: { type: 'array' } + }, + required: ['source', 'transform'] + } + } + const responsePayload = { data, status: 200 } + + apiService.get = jest.fn().mockResolvedValue(responsePayload) + + // Act + await store.dispatch(fetchRdiPipelineSchema('123')) + + // Assert + const expectedMonacoJobsSchema = { + type: 'object', + properties: { + source: { type: 'object' }, + transform: { type: 'array' } + }, + required: ['source', 'transform'] + } + + const expectedActions = [ + setPipelineSchema(data), + setMonacoJobsSchema(expectedMonacoJobsSchema), + setJobNameSchema(null), // default fallback value + ] + + expect(store.getActions()).toEqual(expectedActions) + }) + + it('succeed to fetch data with empty jobs schema', async () => { + const data = { + config: 'string', + jobs: {} + } + const responsePayload = { data, status: 200 } + + apiService.get = jest.fn().mockResolvedValue(responsePayload) + + // Act + await store.dispatch(fetchRdiPipelineSchema('123')) + + // Assert + const expectedActions = [ + setPipelineSchema(data), + setMonacoJobsSchema({ required: [] }), + setJobNameSchema(null), + ] expect(store.getActions()).toEqual(expectedActions) })