diff --git a/packages/java/parser-jvm-plugin-nonnull/pom.xml b/packages/java/parser-jvm-plugin-nonnull/pom.xml index fdabd6e64d..f976b7fc03 100644 --- a/packages/java/parser-jvm-plugin-nonnull/pom.xml +++ b/packages/java/parser-jvm-plugin-nonnull/pom.xml @@ -54,5 +54,15 @@ ${project.version} test + + jakarta.persistence + jakarta.persistence-api + test + + + org.springframework + spring-core + test + diff --git a/packages/java/parser-jvm-plugin-nonnull/src/main/java/com/vaadin/hilla/parser/plugins/nonnull/NonnullPluginConfig.java b/packages/java/parser-jvm-plugin-nonnull/src/main/java/com/vaadin/hilla/parser/plugins/nonnull/NonnullPluginConfig.java index 50a2530a09..40b197e597 100644 --- a/packages/java/parser-jvm-plugin-nonnull/src/main/java/com/vaadin/hilla/parser/plugins/nonnull/NonnullPluginConfig.java +++ b/packages/java/parser-jvm-plugin-nonnull/src/main/java/com/vaadin/hilla/parser/plugins/nonnull/NonnullPluginConfig.java @@ -47,6 +47,11 @@ static class Processor extends ConfigList.Processor { // Package-level annotations have low score new AnnotationMatcher("org.springframework.lang.NonNullApi", false, 10), + // Id and Version annotation usually mark nullable fields for + // CRUD operations. + // Low score allows other annotations to override them. + new AnnotationMatcher("jakarta.persistence.Id", true, 20), + new AnnotationMatcher("jakarta.persistence.Version", true, 20), // Nullable-like annotations get a higher score. This should // only matter when they are used in conjunction with // package-level annotations diff --git a/packages/java/parser-jvm-plugin-nonnull/src/test/java/com/vaadin/hilla/parser/plugins/nonnull/nullable/Endpoint.java b/packages/java/parser-jvm-plugin-nonnull/src/test/java/com/vaadin/hilla/parser/plugins/nonnull/nullable/Endpoint.java new file mode 100644 index 0000000000..e5eab005f8 --- /dev/null +++ b/packages/java/parser-jvm-plugin-nonnull/src/test/java/com/vaadin/hilla/parser/plugins/nonnull/nullable/Endpoint.java @@ -0,0 +1,11 @@ +package com.vaadin.hilla.parser.plugins.nonnull.nullable; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface Endpoint { +} diff --git a/packages/java/parser-jvm-plugin-nonnull/src/test/java/com/vaadin/hilla/parser/plugins/nonnull/nullable/EndpointExposed.java b/packages/java/parser-jvm-plugin-nonnull/src/test/java/com/vaadin/hilla/parser/plugins/nonnull/nullable/EndpointExposed.java new file mode 100644 index 0000000000..0ed232996c --- /dev/null +++ b/packages/java/parser-jvm-plugin-nonnull/src/test/java/com/vaadin/hilla/parser/plugins/nonnull/nullable/EndpointExposed.java @@ -0,0 +1,11 @@ +package com.vaadin.hilla.parser.plugins.nonnull.nullable; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface EndpointExposed { +} diff --git a/packages/java/parser-jvm-plugin-nonnull/src/test/java/com/vaadin/hilla/parser/plugins/nonnull/nullable/NullableEndpoint.java b/packages/java/parser-jvm-plugin-nonnull/src/test/java/com/vaadin/hilla/parser/plugins/nonnull/nullable/NullableEndpoint.java new file mode 100644 index 0000000000..a3ab8aed6c --- /dev/null +++ b/packages/java/parser-jvm-plugin-nonnull/src/test/java/com/vaadin/hilla/parser/plugins/nonnull/nullable/NullableEndpoint.java @@ -0,0 +1,20 @@ +package com.vaadin.hilla.parser.plugins.nonnull.nullable; + +import jakarta.persistence.Id; +import jakarta.persistence.Version; + +@Endpoint +public class NullableEndpoint { + + public NullableFieldModel nullableFieldModel( + NullableFieldModel nullableFieldModel) { + return nullableFieldModel; + } + + public static class NullableFieldModel { + @Id + public String id; + @Version + public Long version; + } +} diff --git a/packages/java/parser-jvm-plugin-nonnull/src/test/java/com/vaadin/hilla/parser/plugins/nonnull/nullable/NullableTest.java b/packages/java/parser-jvm-plugin-nonnull/src/test/java/com/vaadin/hilla/parser/plugins/nonnull/nullable/NullableTest.java new file mode 100644 index 0000000000..4cc06489ee --- /dev/null +++ b/packages/java/parser-jvm-plugin-nonnull/src/test/java/com/vaadin/hilla/parser/plugins/nonnull/nullable/NullableTest.java @@ -0,0 +1,48 @@ +package com.vaadin.hilla.parser.plugins.nonnull.nullable; + +import com.vaadin.hilla.parser.core.Parser; +import com.vaadin.hilla.parser.plugins.backbone.BackbonePlugin; +import com.vaadin.hilla.parser.plugins.nonnull.AnnotationMatcher; +import com.vaadin.hilla.parser.plugins.nonnull.NonnullPlugin; +import com.vaadin.hilla.parser.plugins.nonnull.NonnullPluginConfig; +import com.vaadin.hilla.parser.plugins.nonnull.nullable.nonNullApi.NullableNonNullEndpoint; +import com.vaadin.hilla.parser.plugins.nonnull.test.helpers.TestHelper; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.net.URISyntaxException; +import java.util.Set; + +public class NullableTest { + private final TestHelper helper = new TestHelper(getClass()); + + @Test + public void should_ApplyNullableAnnotation() + throws IOException, URISyntaxException { + var plugin = new NonnullPlugin(); + plugin.setConfiguration(new NonnullPluginConfig()); + + var openAPI = new Parser().classLoader(getClass().getClassLoader()) + .classPath(Set.of(helper.getTargetDir().toString())) + .endpointAnnotation(Endpoint.class.getName()) + .endpointExposedAnnotation(EndpointExposed.class.getName()) + .addPlugin(new BackbonePlugin()).addPlugin(plugin).execute(); + + helper.executeParserWithConfig(openAPI); + } + + @Test + public void annotationMatcher_shouldHaveDefaultConstructorAndSetter() { + // to enable maven initialize instances of AnnotationMatcher from + // pom.xml configurations, properly, it should have the default + // constructor and setter methods: + AnnotationMatcher annotationMatcher = new AnnotationMatcher(); + annotationMatcher.setName("name"); + annotationMatcher.setScore(100); + annotationMatcher.setMakesNullable(true); + Assertions.assertEquals("name", annotationMatcher.getName()); + Assertions.assertEquals(100, annotationMatcher.getScore()); + Assertions.assertTrue(annotationMatcher.doesMakeNullable()); + } +} diff --git a/packages/java/parser-jvm-plugin-nonnull/src/test/java/com/vaadin/hilla/parser/plugins/nonnull/nullable/nonNullApi/NullableNonNullEndpoint.java b/packages/java/parser-jvm-plugin-nonnull/src/test/java/com/vaadin/hilla/parser/plugins/nonnull/nullable/nonNullApi/NullableNonNullEndpoint.java new file mode 100644 index 0000000000..edd5342c0d --- /dev/null +++ b/packages/java/parser-jvm-plugin-nonnull/src/test/java/com/vaadin/hilla/parser/plugins/nonnull/nullable/nonNullApi/NullableNonNullEndpoint.java @@ -0,0 +1,26 @@ +package com.vaadin.hilla.parser.plugins.nonnull.nullable.nonNullApi; + +import com.vaadin.hilla.parser.plugins.nonnull.nullable.Endpoint; +import jakarta.annotation.Nonnull; +import jakarta.persistence.Id; +import jakarta.persistence.Version; + +@Endpoint +public class NullableNonNullEndpoint { + + public NullableNonNullFieldModel nullableNonNullFieldModel( + NullableNonNullFieldModel nullableNonNullFieldModel) { + return nullableNonNullFieldModel; + } + + public static class NullableNonNullFieldModel { + public String required; + @Id + public String id; + @Version + public Long version; + @Version + @Nonnull + public Long notNullVersion; + } +} diff --git a/packages/java/parser-jvm-plugin-nonnull/src/test/java/com/vaadin/hilla/parser/plugins/nonnull/nullable/nonNullApi/package-info.java b/packages/java/parser-jvm-plugin-nonnull/src/test/java/com/vaadin/hilla/parser/plugins/nonnull/nullable/nonNullApi/package-info.java new file mode 100644 index 0000000000..1f8562e6e5 --- /dev/null +++ b/packages/java/parser-jvm-plugin-nonnull/src/test/java/com/vaadin/hilla/parser/plugins/nonnull/nullable/nonNullApi/package-info.java @@ -0,0 +1,4 @@ +@NonNullApi +package com.vaadin.hilla.parser.plugins.nonnull.nullable.nonNullApi; + +import org.springframework.lang.NonNullApi; diff --git a/packages/java/parser-jvm-plugin-nonnull/src/test/resources/com/vaadin/hilla/parser/plugins/nonnull/nullable/openapi.json b/packages/java/parser-jvm-plugin-nonnull/src/test/resources/com/vaadin/hilla/parser/plugins/nonnull/nullable/openapi.json new file mode 100644 index 0000000000..07336d76d8 --- /dev/null +++ b/packages/java/parser-jvm-plugin-nonnull/src/test/resources/com/vaadin/hilla/parser/plugins/nonnull/nullable/openapi.json @@ -0,0 +1,150 @@ +{ + "openapi" : "3.0.1", + "info" : { + "title" : "Hilla Application", + "version" : "1.0.0" + }, + "servers" : [ + { + "url" : "http://localhost:8080/connect", + "description" : "Hilla Backend" + } + ], + "tags" : [ + { + "name" : "NullableEndpoint", + "x-class-name" : "com.vaadin.hilla.parser.plugins.nonnull.nullable.NullableEndpoint" + }, + { + "name" : "NullableNonNullEndpoint", + "x-class-name" : "com.vaadin.hilla.parser.plugins.nonnull.nullable.nonNullApi.NullableNonNullEndpoint" + } + ], + "paths" : { + "/NullableEndpoint/nullableFieldModel" : { + "post" : { + "tags" : [ + "NullableEndpoint" + ], + "operationId" : "NullableEndpoint_nullableFieldModel_POST", + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "type" : "object", + "properties" : { + "nullableFieldModel" : { + "nullable" : true, + "anyOf" : [ + { + "$ref" : "#/components/schemas/com.vaadin.hilla.parser.plugins.nonnull.nullable.NullableEndpoint$NullableFieldModel" + } + ] + } + } + } + } + } + }, + "responses" : { + "200" : { + "description" : "", + "content" : { + "application/json" : { + "schema" : { + "nullable" : true, + "anyOf" : [ + { + "$ref" : "#/components/schemas/com.vaadin.hilla.parser.plugins.nonnull.nullable.NullableEndpoint$NullableFieldModel" + } + ] + } + } + } + } + } + } + }, + "/NullableNonNullEndpoint/nullableNonNullFieldModel" : { + "post" : { + "tags" : [ + "NullableNonNullEndpoint" + ], + "operationId" : "NullableNonNullEndpoint_nullableNonNullFieldModel_POST", + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "type" : "object", + "properties" : { + "nullableNonNullFieldModel" : { + "anyOf" : [ + { + "$ref" : "#/components/schemas/com.vaadin.hilla.parser.plugins.nonnull.nullable.nonNullApi.NullableNonNullEndpoint$NullableNonNullFieldModel" + } + ] + } + } + } + } + } + }, + "responses" : { + "200" : { + "description" : "", + "content" : { + "application/json" : { + "schema" : { + "anyOf" : [ + { + "$ref" : "#/components/schemas/com.vaadin.hilla.parser.plugins.nonnull.nullable.nonNullApi.NullableNonNullEndpoint$NullableNonNullFieldModel" + } + ] + } + } + } + } + } + } + } + }, + "components" : { + "schemas" : { + "com.vaadin.hilla.parser.plugins.nonnull.nullable.NullableEndpoint$NullableFieldModel" : { + "type" : "object", + "properties" : { + "id" : { + "type" : "string", + "nullable" : true + }, + "version" : { + "type" : "integer", + "format" : "int64", + "nullable" : true + } + } + }, + "com.vaadin.hilla.parser.plugins.nonnull.nullable.nonNullApi.NullableNonNullEndpoint$NullableNonNullFieldModel" : { + "type" : "object", + "properties" : { + "required" : { + "type" : "string" + }, + "id" : { + "type" : "string", + "nullable" : true + }, + "version" : { + "type" : "integer", + "format" : "int64", + "nullable" : true + }, + "notNullVersion" : { + "type" : "integer", + "format" : "int64" + } + } + } + } + } +} diff --git a/packages/java/tests/spring/react-grid-test/src/main/java/com/vaadin/hilla/test/reactgrid/AbstractEntity.java b/packages/java/tests/spring/react-grid-test/src/main/java/com/vaadin/hilla/test/reactgrid/AbstractEntity.java index 6fd3f55640..955cee14bb 100644 --- a/packages/java/tests/spring/react-grid-test/src/main/java/com/vaadin/hilla/test/reactgrid/AbstractEntity.java +++ b/packages/java/tests/spring/react-grid-test/src/main/java/com/vaadin/hilla/test/reactgrid/AbstractEntity.java @@ -6,17 +6,13 @@ import jakarta.persistence.MappedSuperclass; import jakarta.persistence.Version; -import com.vaadin.hilla.Nullable; - @MappedSuperclass public class AbstractEntity { @Id - @Nullable @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Version - @Nullable private Long version; public Long getId() { diff --git a/packages/ts/react-crud/src/autoform.tsx b/packages/ts/react-crud/src/autoform.tsx index 17a8847f99..1f63639063 100644 --- a/packages/ts/react-crud/src/autoform.tsx +++ b/packages/ts/react-crud/src/autoform.tsx @@ -12,6 +12,7 @@ import { type ReactElement, useEffect, useMemo, + useRef, useState, } from 'react'; import { AutoFormField, type AutoFormFieldProps, type FieldOptions } from './autoform-field.js'; @@ -278,6 +279,7 @@ export function AutoForm({ const form = useForm(model, { onSubmit: async (formItem) => service.save(formItem), }); + const formErrorRef = useRef(null); const [formError, setFormError] = useState(''); const [showDeleteDialog, setShowDeleteDialog] = useState(false); const modelInfo = useMemo(() => new ModelInfo(model, itemIdProperty), [model]); @@ -294,21 +296,33 @@ export function AutoForm({ } }, [item]); + useEffect(() => { + formErrorRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' }); + }, [formError]); + function handleSubmitError(error: unknown) { if (error instanceof ValidationError) { const nonPropertyErrorMessages = error.errors - .filter((validationError) => !validationError.property) - .map((validationError) => validationError.validatorMessage ?? validationError.message); + .filter((validationError) => !validationError.property || typeof validationError.property === 'string') + .map((validationError) => { + const property = + validationError.property && typeof validationError.property === 'string' + ? `${validationError.property}: ` + : ''; + return `${property}${ + validationError.validatorMessage ? validationError.validatorMessage : validationError.message + }`; + }); if (nonPropertyErrorMessages.length > 0) { setFormError( - <> +
Validation errors:
    {nonPropertyErrorMessages.map((message, index) => (
  • {message}
  • ))}
- , +
, ); } } else if (error instanceof EndpointError) { diff --git a/packages/ts/react-crud/test/autoform.spec.tsx b/packages/ts/react-crud/test/autoform.spec.tsx index aafb79782b..554eb7e651 100644 --- a/packages/ts/react-crud/test/autoform.spec.tsx +++ b/packages/ts/react-crud/test/autoform.spec.tsx @@ -429,6 +429,29 @@ describe('@vaadin/hilla-react-crud', () => { expect(result.queryByText('just a message')).to.not.be.null; }); + it('shows error for whole form has string property', async () => { + const service: CrudService & HasTestInfo = createService(personData); + const person = await getItem(service, 1); + // eslint-disable-next-line @typescript-eslint/require-await + service.save = async (_item: Person): Promise => { + const valueError: ValueError = { + property: 'myProp', + message: 'message', + value: person, + validator: { message: 'message', validate: () => false }, + validatorMessage: 'foobar', + }; + throw new ValidationError([valueError]); + }; + + const result = render(); + const form = await FormController.init(user, result.container); + await form.typeInField('First name', 'J'); // to enable the submit button + await form.submit(); + expect(result.queryByText('message')).to.be.null; + expect(result.queryByText('myProp: foobar')).to.not.be.null; + }); + it('shows a predefined error message when the service returns no entity after saving', async () => { const service: CrudService & HasTestInfo = createService(personData); service.save = async (item: Person): Promise => Promise.resolve(undefined);