Skip to content

Commit

Permalink
Show validation warnings only after the first form submission (#87) (#…
Browse files Browse the repository at this point in the history
…3271)

* Show validation warnings only after the first submission on the metadata editor

* Add integration test

Signed-off-by: Guilherme Caponetto <[email protected]>
  • Loading branch information
caponetto authored Jan 2, 2025
1 parent 6eef65b commit 7944a98
Show file tree
Hide file tree
Showing 3 changed files with 134 additions and 53 deletions.
38 changes: 25 additions & 13 deletions packages/metadata-common/src/MetadataEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@
*/

import { MetadataService } from '@elyra/services';
import { RequestErrors, FormEditor } from '@elyra/ui-components';
import {
RequestErrors,
FormEditor,
IFormEditorRef
} from '@elyra/ui-components';

import * as React from 'react';

Expand Down Expand Up @@ -77,19 +81,30 @@ export const MetadataEditor: React.FC<IMetadataEditorComponentProps> = ({
getDefaultChoices,
titleContext
}: IMetadataEditorComponentProps) => {
const [invalidForm, setInvalidForm] = React.useState(name === undefined);
const formRef = React.useRef<IFormEditorRef>(null);
const [isSubmitted, setSubmitted] = React.useState(false);
const [invalidForm, setInvalidForm] = React.useState(false);

const schema = schemaTop.properties.metadata;

const [metadata, setMetadata] = React.useState(initialMetadata);
const displayName = initialMetadata?.['_noCategory']?.['display_name'];
const referenceURL = schemaTop.uihints?.reference_url;

const isFormDataValid = (data: any) => {
const state = formRef.current!.validateForm(data);
return state.isValid;
};

/**
* Saves metadata through either put or post request.
*/
const saveMetadata = (): void => {
if (invalidForm) {
const saveMetadata = () => {
const isValid = isFormDataValid(metadata);
setInvalidForm(!isValid);
setSubmitted(true);

if (!isValid) {
return;
}

Expand Down Expand Up @@ -167,10 +182,13 @@ export const MetadataEditor: React.FC<IMetadataEditorComponentProps> = ({
) : null}
</p>
<FormEditor
ref={formRef}
schema={schema}
onChange={(formData: any, invalid: boolean): void => {
onChange={(formData: any): void => {
setMetadata(formData);
setInvalidForm(invalid);
if (isSubmitted) {
setInvalidForm(!isFormDataValid(formData));
}
setDirty(true);
}}
componentRegistry={componentRegistry}
Expand All @@ -193,13 +211,7 @@ export const MetadataEditor: React.FC<IMetadataEditorComponentProps> = ({
) : (
<div />
)}
<button
onClick={(): void => {
saveMetadata();
}}
>
{translator.__('Save & Close')}
</button>
<button onClick={saveMetadata}>{translator.__('Save & Close')}</button>
</div>
</div>
);
Expand Down
68 changes: 52 additions & 16 deletions packages/ui-components/src/FormEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ import {
ArrayFieldTemplateProps,
FieldTemplateProps,
RegistryFieldsType,
RegistryWidgetsType
RegistryWidgetsType,
RJSFValidationError
} from '@rjsf/utils';
import validator from '@rjsf/validator-ajv8';
import * as React from 'react';
Expand All @@ -37,9 +38,9 @@ interface IFormEditorProps {
schema: any;

/**
* Handler to update form data / error state in parent component.
* Handler to update form data in parent component.
*/
onChange: (formData: any, invalid: boolean) => void;
onChange: (formData: any) => void;

/**
* Editor services to create new editors in code fields.
Expand Down Expand Up @@ -72,6 +73,19 @@ interface IFormEditorProps {
languageOptions?: string[];
}

export type FormValidationState =
| {
isValid: true;
}
| {
isValid: false;
errors: RJSFValidationError[];
};

export interface IFormEditorRef {
validateForm: (data: any) => FormValidationState;
}

/**
* React component that allows for custom add / remove buttons in the array
* field component.
Expand Down Expand Up @@ -138,17 +152,24 @@ const CustomFieldTemplate: React.FC<FieldTemplateProps> = (props) => {
* Creates a uiSchema from given uihints and passes relevant information
* to the custom renderers.
*/
export const FormEditor: React.FC<IFormEditorProps> = ({
schema,
onChange,
editorServices,
componentRegistry,
translator,
originalData,
allTags,
languageOptions
}) => {
const [formData, setFormData] = React.useState(originalData ?? ({} as any));
const RefForwardingFormEditor: React.ForwardRefRenderFunction<
IFormEditorRef,
IFormEditorProps
> = (
{
schema,
onChange,
editorServices,
componentRegistry,
translator,
originalData,
allTags,
languageOptions
},
forwardedRef
) => {
const [formData, setFormData] = React.useState(originalData ?? {});
const [liveValidateEnabled, setLiveValidateEnabled] = React.useState(false);

/**
* Generate the rjsf uiSchema from uihints in the elyra metadata schema.
Expand Down Expand Up @@ -177,6 +198,19 @@ export const FormEditor: React.FC<IFormEditorProps> = ({
.map(([key, value]) => [key, value.widgetRenderer!])
);

React.useImperativeHandle(
forwardedRef,
(): IFormEditorRef => ({
validateForm: (data) => {
setLiveValidateEnabled(true);
const result = validator.validateFormData(data, schema);
return result.errors.length === 0
? { isValid: true }
: { isValid: false, errors: result.errors };
}
})
);

return (
<Form
schema={schema}
Expand All @@ -198,13 +232,15 @@ export const FormEditor: React.FC<IFormEditorProps> = ({
uiSchema={uiSchema}
onChange={(e): void => {
setFormData(e.formData);
onChange(e.formData, e.errors.length > 0 || false);
onChange(e.formData);
}}
liveValidate={true}
liveValidate={liveValidateEnabled}
noHtml5Validate={
/** noHtml5Validate is set to true to prevent the html validation from moving the focus when the live validate is called. */
true
}
/>
);
};

export const FormEditor = React.forwardRef(RefForwardingFormEditor);
81 changes: 57 additions & 24 deletions tests/integration/codesnippet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,26 +38,44 @@ describe('Code Snippet tests', () => {
);
});

it('should show validation warnings only after submission', () => {
const name = 'foo';

clickCreateNewSnippetButton();

checkValidationWarnings(0);

typeCodeSnippetName(name);

saveAndCloseMetadataEditor();

checkValidationWarnings(2);

populateCodeSnippetFields(name);

checkValidationWarnings(0);

saveAndCloseMetadataEditor();

checkEditorVisibility(false);

getSnippetByName(name);

deleteSnippet(name);
});

it('should provide warnings when required fields are not entered properly', () => {
createInvalidCodeSnippet(snippetName);

// Metadata editor should not close
cy.get('.lm-TabBar-tabLabel')
.contains('New Code Snippet')
.should('be.visible');
checkEditorVisibility(true);

// Fields marked as required should be highlighted
cy.get('.error-detail li.text-danger').as('required-warnings');
cy.get('@required-warnings').should('have.length', 2);
checkValidationWarnings(2);
});

it('should create valid code-snippet', () => {
it.skip('should create valid code-snippet', () => {
createValidCodeSnippet(snippetName);

// Metadata editor tab should not be visible
cy.get('.lm-TabBar-tabLabel')
.contains('New Code Snippet')
.should('not.exist');
checkEditorVisibility(false);

// Check new code snippet is displayed
getSnippetByName(snippetName);
Expand All @@ -78,14 +96,13 @@ describe('Code Snippet tests', () => {
});

it('should trigger save / submit on pressing enter', () => {
clickCreateNewSnippetButton();

populateCodeSnippetFields(snippetName);

cy.get('.elyra-formEditor-form-display_name').type('{enter}');
typeCodeSnippetName('{enter}');

// Metadata editor tab should not be visible
cy.get('.lm-TabBar-tabLabel')
.contains('New Code Snippet')
.should('not.exist');
checkEditorVisibility(false);

// Check new code snippet is displayed
getSnippetByName(snippetName);
Expand Down Expand Up @@ -344,20 +361,17 @@ const getSnippetByName = (snippetName: string): any => {
const createInvalidCodeSnippet = (snippetName: string): any => {
clickCreateNewSnippetButton();

// Name code snippet
cy.get('.elyra-formEditor-form-display_name').type(snippetName);
typeCodeSnippetName(snippetName);

saveAndCloseMetadataEditor();
};

const populateCodeSnippetFields = (
snippetName: string,
language?: string
): any => {
clickCreateNewSnippetButton();

): void => {
// Name code snippet
cy.get('.elyra-formEditor-form-display_name').type(snippetName);
cy.get('.elyra-formEditor-form-display_name').clear().type(snippetName);

// Select python language from dropdown list
editSnippetLanguage(snippetName, language ?? 'Python');
Expand All @@ -372,7 +386,9 @@ const populateCodeSnippetFields = (
const createValidCodeSnippet = (
snippetName: string,
language?: string
): any => {
): void => {
clickCreateNewSnippetButton();

populateCodeSnippetFields(snippetName, language);

saveAndCloseMetadataEditor();
Expand All @@ -384,10 +400,27 @@ const clickCreateNewSnippetButton = (): void => {
cy.findByRole('button', { name: /create new code snippet/i }).click();
};

const checkValidationWarnings = (count: number): void => {
cy.get('.error-detail li.text-danger').should(
count === 0 ? 'not.exist' : 'have.length',
count
);
};

const saveAndCloseMetadataEditor = (): void => {
cy.get('.elyra-metadataEditor-saveButton > button:visible').click();
};

const typeCodeSnippetName = (name: string): void => {
cy.get('.elyra-formEditor-form-display_name').type(name);
};

const checkEditorVisibility = (isVisible: boolean): void => {
cy.get('.lm-TabBar-tabLabel')
.contains('New Code Snippet')
.should(isVisible ? 'be.visible' : 'not.exist');
};

const deleteSnippet = (snippetName: string): void => {
// Find element by name
const item = getSnippetByName(snippetName);
Expand Down

0 comments on commit 7944a98

Please sign in to comment.