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/pipeline-actions/PipelineActions.spec.tsx b/redisinsight/ui/src/pages/rdi/instance/components/header/components/pipeline-actions/PipelineActions.spec.tsx index ed04e49026..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', }) @@ -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', }) @@ -203,6 +209,95 @@ 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', () => { beforeEach(() => { const sendEventTelemetryMock = jest.fn() 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 dc585430b9..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 @@ -38,6 +38,8 @@ const PipelineActions = ({ collectorStatus, pipelineStatus }: Props) => { const { loading: deployLoading, schema, + monacoJobsSchema, + jobNameSchema, config, jobs, } = useSelector(rdiPipelineSelector) @@ -56,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)) 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) })