Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ballerine Validator(WIP) #2874

Open
wants to merge 7 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,9 @@
"clsx": "^1.2.1",
"cmdk": "^0.2.0",
"dayjs": "^1.11.6",
"email-validator": "^2.0.4",
"i18n-iso-countries": "^7.6.0",
"json-logic-js": "^2.0.2",
"lodash": "^4.17.21",
"lucide-react": "^0.144.0",
"react": "^18.0.37",
Expand All @@ -76,6 +78,10 @@
"@storybook/react": "^7.0.26",
"@storybook/react-vite": "^7.0.26",
"@storybook/testing-library": "^0.0.14-next.2",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^13.3.0",
"@types/json-logic-js": "^2.0.1",
"@types/jsoneditor": "^9.9.5",
"@types/lodash": "^4.14.191",
"@types/node": "^20.4.1",
"@types/react": "^18.0.37",
Expand All @@ -90,6 +96,7 @@
"eslint-plugin-react-refresh": "^0.4.1",
"eslint-plugin-storybook": "^0.6.6",
"fast-glob": "^3.3.0",
"jsoneditor": "^10.1.0",
"prop-types": "^15.8.1",
"rimraf": "^5.0.5",
"storybook": "^7.0.26",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Story } from './_stories/components/Story';
import { ValidatorProvider } from './ValidatorProvider';

export default {
component: ValidatorProvider,
};

export const Default = {
render: () => <Story />,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { useMemo } from 'react';
import { IValidatorContext, ValidatorContext } from './context';
import { checkIfValid } from './helpers';
import { useValidate } from './hooks/internal/useValidate';
import { IValidatorRef, useValidatorRef } from './hooks/internal/useValidatorRef';
import { IValidationSchema } from './types';

export interface IValidatorProviderProps<TValue extends object> {
children: React.ReactNode | React.ReactNode[];
schema: IValidationSchema[];
value: TValue;

ref?: React.RefObject<IValidatorRef>;
validateOnChange?: boolean;
validateSync?: boolean;
validationDelay?: number;
abortEarly?: boolean;
}

export const ValidatorProvider = <TValue extends object>({
children,
schema,
value,
validateOnChange,
validateSync,
abortEarly,
validationDelay,
ref,
}: IValidatorProviderProps<TValue>) => {
useValidatorRef(ref);
const { errors, validate } = useValidate(value, schema, {
abortEarly,
validateSync,
validateOnChange,
validationDelay,
});

const context: IValidatorContext<TValue> = useMemo(
() => ({ errors, values: value, isValid: checkIfValid(errors), validate }),
[errors, value, validate],
);

return <ValidatorContext.Provider value={context}>{children}</ValidatorContext.Provider>;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import JSONEditor from 'jsoneditor';
import 'jsoneditor/dist/jsoneditor.css';
import { FunctionComponent, useEffect, useRef } from 'react';

interface IJSONEditorProps {
value: any;
readOnly?: boolean;
onChange?: (value: any) => void;
}

export const JSONEditorComponent: FunctionComponent<IJSONEditorProps> = ({
value,
readOnly,
onChange,
}) => {
const containerRef = useRef<HTMLDivElement | null>(null);
const editorRef = useRef<JSONEditor | null>(null);

useEffect(() => {
if (!containerRef.current) return;

if (editorRef.current) return;

editorRef.current = new JSONEditor(containerRef.current!, {
onChange: () => {
editorRef.current && onChange && onChange(editorRef.current.get());
},
});
}, [containerRef, editorRef]);
Comment on lines +19 to +29
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Fix useEffect dependencies and add cleanup

The current implementation has two issues:

  1. Missing onChange in the dependencies array could lead to stale closures
  2. Missing cleanup function could lead to memory leaks
  useEffect(() => {
    if (!containerRef.current) return;
    if (editorRef.current) return;

    editorRef.current = new JSONEditor(containerRef.current!, {
      onChange: () => {
        editorRef.current && onChange && onChange(editorRef.current.get());
      },
    });
+   
+   return () => {
+     if (editorRef.current) {
+       editorRef.current.destroy();
+       editorRef.current = null;
+     }
+   };
-  }, [containerRef, editorRef]);
+  }, [containerRef, editorRef, onChange]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
useEffect(() => {
if (!containerRef.current) return;
if (editorRef.current) return;
editorRef.current = new JSONEditor(containerRef.current!, {
onChange: () => {
editorRef.current && onChange && onChange(editorRef.current.get());
},
});
}, [containerRef, editorRef]);
useEffect(() => {
if (!containerRef.current) return;
if (editorRef.current) return;
editorRef.current = new JSONEditor(containerRef.current!, {
onChange: () => {
editorRef.current && onChange && onChange(editorRef.current.get());
},
});
return () => {
if (editorRef.current) {
editorRef.current.destroy();
editorRef.current = null;
}
};
}, [containerRef, editorRef, onChange]);


useEffect(() => {
if (!editorRef.current) return;

//TODO: Each set of value rerenders editor and loses focus, find workarounds
editorRef.current.set(value);
}, [editorRef, readOnly]);

Comment on lines +31 to +37
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Fix incorrect dependencies and remove redundant effect

The effect has readOnly in its dependencies but doesn't use it. Also, this effect seems redundant with the next one.

-  useEffect(() => {
-    if (!editorRef.current) return;
-
-    //TODO: Each set of value rerenders editor and loses focus, find workarounds
-    editorRef.current.set(value);
-  }, [editorRef, readOnly]);

useEffect(() => {
if (!editorRef.current) return;

if (readOnly) {
editorRef.current.set(value);
}
}, [editorRef, readOnly, value]);

useEffect(() => {
if (!editorRef.current) return;

editorRef.current.setMode(readOnly ? 'view' : 'code');
}, [readOnly]);

return <div className="h-full" ref={containerRef} />;
};
Comment on lines +52 to +53
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add error boundary and loading state

The component should handle JSON parsing errors gracefully and show a loading state during initialization.

+ import { ErrorBoundary } from 'react-error-boundary';
+ 
+ const EditorErrorFallback = ({ error, resetErrorBoundary }) => (
+   <div className="h-full p-4 text-red-500">
+     <p>Error loading editor: {error.message}</p>
+     <button onClick={resetErrorBoundary}>Try again</button>
+   </div>
+ );

  export const JSONEditorComponent: FunctionComponent<IJSONEditorProps> = ({
    value,
    readOnly,
    onChange,
  }) => {
    const containerRef = useRef<HTMLDivElement | null>(null);
    const editorRef = useRef<JSONEditor | null>(null);
+   const [isLoading, setIsLoading] = useState(true);

    // ... existing effects ...

+   useEffect(() => {
+     if (editorRef.current) {
+       setIsLoading(false);
+     }
+   }, [editorRef.current]);

-   return <div className="h-full" ref={containerRef} />;
+   return (
+     <ErrorBoundary FallbackComponent={EditorErrorFallback}>
+       <div className="h-full relative" ref={containerRef}>
+         {isLoading && (
+           <div className="absolute inset-0 flex items-center justify-center bg-white bg-opacity-50">
+             Loading editor...
+           </div>
+         )}
+       </div>
+     </ErrorBoundary>
+   );
  };

Committable suggestion skipped: line range outside the PR's diff.

Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { useValidator } from '../../../hooks/external/useValidator';

export const ErrorsList = () => {
const { errors } = useValidator();

return (
<div className="flex flex-col gap-1">
{errors.map((error, index) => (
<div key={index}>{JSON.stringify(error)}</div>
))}
Comment on lines +8 to +10
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Avoid using array index as React key

Using array index as key can lead to rendering issues when the array items change. Consider using a unique identifier from the error object or a stable hash of the error content.

-{errors.map((error, index) => (
-  <div key={index}>{JSON.stringify(error)}</div>
-))}
+{errors.map((error) => (
+  <div key={typeof error === 'string' ? error : JSON.stringify(error)}>
+    {typeof error === 'string' ? error : JSON.stringify(error)}
+  </div>
+))}

Committable suggestion skipped: line range outside the PR's diff.

</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { useState } from 'react';
import { IValidationSchema, registerValidator, ValidatorProvider } from '../../../../Validator';
import { JSONEditorComponent } from '../../../../Validator/_stories/components/JsonEditor/JsonEditor';
import { ValidatorParams } from '../../../../Validator/_stories/components/ValidatorParams';
import { initialContext } from './context';
import { ErrorsList } from './ErrorsList';
import { initialSchema } from './schema';

const evenNumbersValidator = (value: number) => {
// Ignoring validation if value is not a number
if (isNaN(value)) return true;

if (value % 2 !== 0) {
throw new Error('Value is not even');
}

return true;
};
Comment on lines +9 to +18
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Improve evenNumbersValidator implementation

The validator needs several improvements:

  1. Replace unsafe isNaN with Number.isNaN
  2. Add proper type checking
  3. Improve error message
-const evenNumbersValidator = (value: number) => {
+const evenNumbersValidator = (value: unknown): boolean => {
   // Ignoring validation if value is not a number
-  if (isNaN(value)) return true;
+  if (typeof value !== 'number' || Number.isNaN(value)) return true;

   if (value % 2 !== 0) {
-    throw new Error('Value is not even');
+    throw new Error(`Value ${value} is not an even number`);
   }

   return true;
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const evenNumbersValidator = (value: number) => {
// Ignoring validation if value is not a number
if (isNaN(value)) return true;
if (value % 2 !== 0) {
throw new Error('Value is not even');
}
return true;
};
const evenNumbersValidator = (value: unknown): boolean => {
// Ignoring validation if value is not a number
if (typeof value !== 'number' || Number.isNaN(value)) return true;
if (value % 2 !== 0) {
throw new Error(`Value ${value} is not an even number`);
}
return true;
};
🧰 Tools
🪛 Biome (1.9.4)

[error] 11-11: isNaN is unsafe. It attempts a type coercion. Use Number.isNaN instead.

See the MDN documentation for more details.
Unsafe fix: Use Number.isNaN instead.

(lint/suspicious/noGlobalIsNan)


registerValidator('evenNumber', evenNumbersValidator);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add cleanup for validator registration

The validator registration should be cleaned up when the component unmounts.

+import { useEffect } from 'react';

 // ... other code ...

-registerValidator('evenNumber', evenNumbersValidator);
+useEffect(() => {
+  registerValidator('evenNumber', evenNumbersValidator);
+  return () => {
+    // Assuming there's an unregisterValidator function
+    unregisterValidator('evenNumber');
+  };
+}, []);

Committable suggestion skipped: line range outside the PR's diff.


export const Story = () => {
const [context, setContext] = useState(initialContext);
const [validatorParams, setValidatorParams] = useState<{
validateOnChange?: boolean;
validateSync?: boolean;
abortEarly?: boolean;
validationDelay?: number;
}>({
validateOnChange: true,
validateSync: false,
abortEarly: false,
validationDelay: 500,
});
const [schema, setSchema] = useState<IValidationSchema[]>(initialSchema);
const [tempSchema, setTempSchema] = useState<IValidationSchema[]>(initialSchema);

return (
<ValidatorProvider schema={schema} value={context} {...validatorParams}>
<div className="flex min-h-screen flex-col gap-4">
<ValidatorParams
params={validatorParams}
onChange={setValidatorParams}
onSave={() => setSchema(tempSchema)}
/>
<div className="flex flex-1 flex-row gap-4">
<div className="flex flex-1 flex-col">
<p className="mb-2">Context</p>
<div className="flex-1">
<JSONEditorComponent value={context} onChange={setContext} />
</div>
</div>
<div className="flex flex-1 flex-col">
<div className="mb-2 flex flex-row gap-2">
<p>Validation Schema</p>
</div>
<div className="flex-1">
<JSONEditorComponent value={tempSchema} onChange={setTempSchema} />
</div>
</div>
</div>
<div className="h-[240px] overflow-y-auto py-2">
<ErrorsList />
</div>
</div>
</ValidatorProvider>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export const initialContext = {
firstName: 'John',
lastName: 'Doe',
age: 20,
list: ['item1', 'item2', 'item3'],
nestedList: [
{
value: 'value1',
},
{
value: 'value2',
},
{
value: 'value3',
sublist: [{ value: 'subitem1' }, { value: 'subitem2' }, { value: 'subitem3' }],
},
],
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './Story';
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { IValidationSchema } from '../../../types';

export const initialSchema: IValidationSchema[] = [
{
id: 'firstname-field',
valueDestination: 'firstName',
validators: [
{
type: 'required',
message: 'Name is required',
value: {},
},
{
type: 'minLength',
value: { minLength: 1 },
message: 'Name must be at least {minLength} characters long',
},
{
type: 'maxLength',
value: { maxLength: 10 },
message: 'Name must be at most {maxLength} characters long',
},
],
},
{
id: 'lastname-field',
valueDestination: 'lastName',
validators: [
{
type: 'required',
message: 'Last name is required',
value: {},
},
],
},
{
id: 'age-field',
valueDestination: 'age',
validators: [
{
type: 'required',
message: 'Age is required',
value: {},
applyWhen: {
type: 'json-logic',
value: {
and: [{ '!!': { var: 'firstName' } }, { '!!': { var: 'lastName' } }],
},
},
},
{
type: 'minimum',
value: { minimum: 18 },
message: 'You must be at least {minimum} years old',
},
{
type: 'maximum',
value: { maximum: 65 },
message: 'You must be at most {maximum} years old',
},
],
},
{
id: 'list-field',
valueDestination: 'list',
validators: [
{
type: 'required',
message: 'List is required',
value: {},
},
{
type: 'minLength',
value: { minLength: 1 },
message: 'List must be at least {minLength} items long',
},
],
children: [
{
id: 'list-item',
valueDestination: 'list[$0]',
validators: [
{
type: 'maxLength',
message: 'Item must be at most {maxLength} characters long',
value: { maxLength: 5 },
},
],
},
],
},
{
id: 'nested-list',
valueDestination: 'nestedList',
validators: [
{
type: 'required',
value: {},
message: 'Nested list is required',
},
],
children: [
{
id: 'nested-list-item-value',
valueDestination: 'nestedList[$0].value',
validators: [
{
type: 'required',
value: {},
message: 'Nested list item value is required',
},
],
},
{
id: 'nested-list-item-sublist',
valueDestination: 'nestedList[$0].sublist',
validators: [],
children: [
{
id: 'nested-list-subitem',
valueDestination: 'nestedList[$0].sublist[$1].value',
validators: [
{
type: 'maxLength',
value: { maxLength: 10 },
message: 'Subitem must be at most {maxLength} characters long',
},
],
},
],
},
],
},
];
Comment on lines +1 to +134
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codebase verification

Additional test coverage needed for complex validation scenarios

Based on the test files examined, there are gaps in test coverage that need to be addressed:

  1. No tests found for nested structure validation (parent-child relationships in the schema)
  2. Missing tests for conditional validation using applyWhen with json-logic
  3. No coverage for array index handling (e.g., list[$0], nestedList[$0].sublist[$1].value)
  4. While basic message formatting is tested, complex nested path validation messages need coverage

The existing tests only cover basic validator functionality (min-length, required) and registration, but not the complex scenarios present in the schema.

🔗 Analysis chain

Ensure comprehensive test coverage

Given the complexity of the validation schema, especially with nested structures and conditional validations, ensure that there are comprehensive tests covering:

  1. Conditional validation scenarios
  2. Nested list validation
  3. Array index handling
  4. Error message formatting

Let's check the test coverage:

🏁 Scripts executed

The following scripts were executed for the analysis:

Script:

#!/bin/bash
# Search for related test files
fd -g "*validator*.test.{ts,tsx}"

Length of output: 1186


Script:

#!/bin/bash
# Let's check the content of some key test files to verify coverage of complex scenarios
echo "=== Checking test coverage for nested structures ==="
rg -A 5 "describe.*nest" packages/ui/src/components/organisms/Form/Validator/**/*.test.{ts,tsx}

echo -e "\n=== Checking test coverage for conditional validation ==="
rg -A 5 "describe.*condition|applyWhen" packages/ui/src/components/organisms/Form/Validator/**/*.test.{ts,tsx}

echo -e "\n=== Checking test coverage for array handling ==="
rg -A 5 "describe.*array|\[\$\d\]" packages/ui/src/components/organisms/Form/Validator/**/*.test.{ts,tsx}

echo -e "\n=== Checking test coverage for error message formatting ==="
rg -A 5 "message.*\{.*\}" packages/ui/src/components/organisms/Form/Validator/**/*.test.{ts,tsx}

Length of output: 2012


Script:

#!/bin/bash
# Let's try a different approach to check test contents
# First, let's check min-length validator tests as they handle array scenarios
cat packages/ui/src/components/organisms/Form/Validator/validators/min-length/min-length.validator.unit.test.ts

echo -e "\n=== Checking required validator for conditional validation ==="
cat packages/ui/src/components/organisms/Form/Validator/validators/required/required.validator.unit.test.ts

echo -e "\n=== Checking validator registration for nested structures ==="
cat packages/ui/src/components/organisms/Form/Validator/utils/register-validator/register-validator.unit.test.ts

Length of output: 4276

Loading
Loading