From f3af36dc31eb36890eb6e8cfb9ae4faa622d1e0b Mon Sep 17 00:00:00 2001 From: Paul Cowan Date: Mon, 6 Feb 2023 11:31:42 +0000 Subject: [PATCH 1/8] add failing test to createAsyncValidators Signed-off-by: Paul Cowan --- .../Stepper/createAsyncValidators.test.ts | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/plugins/scaffolder-react/src/next/components/Stepper/createAsyncValidators.test.ts b/plugins/scaffolder-react/src/next/components/Stepper/createAsyncValidators.test.ts index 6ad14e3cc4..ff7060222d 100644 --- a/plugins/scaffolder-react/src/next/components/Stepper/createAsyncValidators.test.ts +++ b/plugins/scaffolder-react/src/next/components/Stepper/createAsyncValidators.test.ts @@ -154,4 +154,83 @@ describe('createAsyncValidators', () => { }), }); }); + + it('should run validation on complex nested schemas', async () => { + const schema: JsonObject = { + title: 'Select component', + properties: { + actionType: { + title: 'action type', + type: 'string', + description: 'Select the action type', + enum: ['newThing', 'existingThing'], + enumNames: ['New thing', 'Existing thing'], + default: 'newThing', + }, + }, + required: ['actionType'], + dependencies: { + actionType: { + oneOf: [ + { + properties: { + actionType: { + enum: ['newThing'], + }, + general: { + title: 'General', + type: 'object', + properties: { + name: { + title: 'Name', + type: 'string', + 'ui:field': 'NameField', + }, + }, + }, + }, + }, + { + properties: { + actionType: { + enum: ['existingThing'], + }, + thingId: { + title: 'Thing id', + type: 'string', + description: 'Enter thing id', + }, + }, + }, + ], + }, + }, + }; + + const NameField: NextCustomFieldValidator = ( + value, + { addError }, + ) => { + if (!value) { + addError('something is broken here!'); + } + }; + + const validators = { + NameField: NameField as NextCustomFieldValidator, + }; + + const validate = createAsyncValidators(schema, validators, { + apiHolder: { get: jest.fn() }, + }); + + await validate({ + actionType: 'newThing', + general: { + name: undefined, + }, + }); + + expect(validators.NameField).toHaveBeenCalled(); + }); }); From 5555e1731349e55060b87d688f7ba6fa23077e88 Mon Sep 17 00:00:00 2001 From: Paul Cowan Date: Mon, 6 Feb 2023 14:34:09 +0000 Subject: [PATCH 2/8] refactor createAsyncValidators to be recursive Signed-off-by: Paul Cowan --- .changeset/shy-buses-mix.md | 5 +++ .../scaffolder/customScaffolderExtensions.tsx | 4 +-- .../src/next/components/Stepper/Stepper.tsx | 7 ++-- .../Stepper/createAsyncValidators.test.ts | 16 ++++++--- .../Stepper/createAsyncValidators.ts | 35 +++++++++++++----- .../src/next/components/Stepper/guards.ts | 36 +++++++++++++++++++ 6 files changed, 82 insertions(+), 21 deletions(-) create mode 100644 .changeset/shy-buses-mix.md create mode 100644 plugins/scaffolder-react/src/next/components/Stepper/guards.ts diff --git a/.changeset/shy-buses-mix.md b/.changeset/shy-buses-mix.md new file mode 100644 index 0000000000..868da1cc93 --- /dev/null +++ b/.changeset/shy-buses-mix.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-react': minor +--- + +refactor createAsyncValidators to be recursive diff --git a/packages/app/src/components/scaffolder/customScaffolderExtensions.tsx b/packages/app/src/components/scaffolder/customScaffolderExtensions.tsx index c521bcf86c..fc37ff9e96 100644 --- a/packages/app/src/components/scaffolder/customScaffolderExtensions.tsx +++ b/packages/app/src/components/scaffolder/customScaffolderExtensions.tsx @@ -68,7 +68,7 @@ export const LowerCaseValuePickerFieldExtension = scaffolderPlugin.provide( const MockDelayComponent = ( props: NextFieldExtensionComponentProps<{ test?: string }>, ) => { - const { onChange, formData, rawErrors } = props; + const { onChange, formData, rawErrors = [] } = props; return ( onChange({ test: value })} margin="normal" - error={rawErrors?.length > 0 && !formData} + error={rawErrors.length > 0 && !formData} /> ); }; diff --git a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx index c97d31c487..bd298fcbae 100644 --- a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx +++ b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx @@ -35,6 +35,7 @@ import { useFormDataFromQuery } from '../../hooks'; import { FormProps } from '../../types'; import { LayoutOptions } from '../../../layouts'; import { useTransformSchemaToProps } from '../../hooks/useTransformSchemaToProps'; +import { isInvalid } from './guards'; const useStyles = makeStyles(theme => ({ backButton: { @@ -136,11 +137,7 @@ export const Stepper = (stepperProps: StepperProps) => { const returnedValidation = await validation(formData); - const hasErrors = Object.values(returnedValidation).some( - i => i.__errors?.length, - ); - - if (hasErrors) { + if (isInvalid(returnedValidation)) { setErrors(returnedValidation); } else { setErrors(undefined); diff --git a/plugins/scaffolder-react/src/next/components/Stepper/createAsyncValidators.test.ts b/plugins/scaffolder-react/src/next/components/Stepper/createAsyncValidators.test.ts index ff7060222d..15fa944b0f 100644 --- a/plugins/scaffolder-react/src/next/components/Stepper/createAsyncValidators.test.ts +++ b/plugins/scaffolder-react/src/next/components/Stepper/createAsyncValidators.test.ts @@ -224,13 +224,19 @@ describe('createAsyncValidators', () => { apiHolder: { get: jest.fn() }, }); - await validate({ - actionType: 'newThing', + await expect( + validate({ + actionType: 'newThing', + general: { + name: undefined, + }, + }), + ).resolves.toEqual({ general: { - name: undefined, + name: expect.objectContaining({ + __errors: ['something is broken here!'], + }), }, }); - - expect(validators.NameField).toHaveBeenCalled(); }); }); diff --git a/plugins/scaffolder-react/src/next/components/Stepper/createAsyncValidators.ts b/plugins/scaffolder-react/src/next/components/Stepper/createAsyncValidators.ts index 79466ee602..299a2451ce 100644 --- a/plugins/scaffolder-react/src/next/components/Stepper/createAsyncValidators.ts +++ b/plugins/scaffolder-react/src/next/components/Stepper/createAsyncValidators.ts @@ -15,12 +15,21 @@ */ import { FieldValidation } from '@rjsf/utils'; -import { JsonObject } from '@backstage/types'; +import type { JsonObject, JsonValue } from '@backstage/types'; import { ApiHolder } from '@backstage/core-plugin-api'; import { Draft07 as JSONSchema } from 'json-schema-library'; import { createFieldValidation } from '../../lib'; import { NextCustomFieldValidator } from '../../extensions'; +function isObject(value: JsonValue | undefined): value is JsonObject { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +type FormValidation = Record< + string, + FieldValidation | Record +>; + export const createAsyncValidators = ( rootSchema: JsonObject, validators: Record>, @@ -28,14 +37,17 @@ export const createAsyncValidators = ( apiHolder: ApiHolder; }, ) => { - async function validate(formData: JsonObject, pathPrefix: string = '#') { + async function validate( + formData: JsonObject, + pathPrefix: string = '#', + current: JsonObject = formData, + ): Promise> { const parsedSchema = new JSONSchema(rootSchema); - const formValidation: Record = {}; - for (const [key, value] of Object.entries(formData)) { - const definitionInSchema = parsedSchema.getSchema( - `${pathPrefix}/${key}`, - formData, - ); + const formValidation: FormValidation = {}; + + for (const [key, value] of Object.entries(current)) { + const path = `${pathPrefix}/${key}`; + const definitionInSchema = parsedSchema.getSchema(path, formData); if (definitionInSchema && 'ui:field' in definitionInSchema) { const validator = validators[definitionInSchema['ui:field']]; @@ -48,10 +60,15 @@ export const createAsyncValidators = ( } formValidation[key] = fieldValidation; } + } else if (isObject(value)) { + formValidation[key] = (await validate(formData, path, value)) as Record< + string, + FieldValidation + >; } } - return formValidation; + return formValidation as Record; } return async (formData: JsonObject) => { diff --git a/plugins/scaffolder-react/src/next/components/Stepper/guards.ts b/plugins/scaffolder-react/src/next/components/Stepper/guards.ts new file mode 100644 index 0000000000..8dfedabd77 --- /dev/null +++ b/plugins/scaffolder-react/src/next/components/Stepper/guards.ts @@ -0,0 +1,36 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import type { FieldValidation } from '@rjsf/utils'; + +function isFieldValidation(error: any): error is FieldValidation { + return !!error && '__errors' in error; +} + +export function isInvalid(errors?: Record): boolean { + if (!errors) { + return false; + } + + for (const error of Object.values(errors)) { + if (isFieldValidation(error)) { + return (error.__errors ?? []).length > 0; + } + + return isInvalid(error); + } + + return false; +} From ba437049cc004ec0c6f29ba79c7e99be2cdf41d4 Mon Sep 17 00:00:00 2001 From: Paul Cowan Date: Mon, 6 Feb 2023 14:42:25 +0000 Subject: [PATCH 3/8] rename isInvalid to hasErrors Signed-off-by: Paul Cowan --- .../scaffolder-react/src/next/components/Stepper/Stepper.tsx | 4 ++-- .../scaffolder-react/src/next/components/Stepper/guards.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx index bd298fcbae..e6e3295aaf 100644 --- a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx +++ b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx @@ -35,7 +35,7 @@ import { useFormDataFromQuery } from '../../hooks'; import { FormProps } from '../../types'; import { LayoutOptions } from '../../../layouts'; import { useTransformSchemaToProps } from '../../hooks/useTransformSchemaToProps'; -import { isInvalid } from './guards'; +import { hasErrors } from './guards'; const useStyles = makeStyles(theme => ({ backButton: { @@ -137,7 +137,7 @@ export const Stepper = (stepperProps: StepperProps) => { const returnedValidation = await validation(formData); - if (isInvalid(returnedValidation)) { + if (hasErrors(returnedValidation)) { setErrors(returnedValidation); } else { setErrors(undefined); diff --git a/plugins/scaffolder-react/src/next/components/Stepper/guards.ts b/plugins/scaffolder-react/src/next/components/Stepper/guards.ts index 8dfedabd77..c7afeb5664 100644 --- a/plugins/scaffolder-react/src/next/components/Stepper/guards.ts +++ b/plugins/scaffolder-react/src/next/components/Stepper/guards.ts @@ -19,7 +19,7 @@ function isFieldValidation(error: any): error is FieldValidation { return !!error && '__errors' in error; } -export function isInvalid(errors?: Record): boolean { +export function hasErrors(errors?: Record): boolean { if (!errors) { return false; } @@ -29,7 +29,7 @@ export function isInvalid(errors?: Record): boolean { return (error.__errors ?? []).length > 0; } - return isInvalid(error); + return hasErrors(error); } return false; From 65cffff8d0e4861e2e78fa09e803fb74bfeefb7f Mon Sep 17 00:00:00 2001 From: Paul Cowan Date: Mon, 6 Feb 2023 14:56:39 +0000 Subject: [PATCH 4/8] refactor types Signed-off-by: Paul Cowan --- .../src/next/components/Stepper/Stepper.tsx | 9 +++++---- .../src/next/components/Stepper/createAsyncValidators.ts | 9 ++++++--- .../src/next/components/Stepper/guards.ts | 3 ++- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx index e6e3295aaf..eb30329cc6 100644 --- a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx +++ b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx @@ -27,7 +27,10 @@ import { ErrorSchema, FieldValidation } from '@rjsf/utils'; import React, { useCallback, useMemo, useState, type ReactNode } from 'react'; import { NextFieldExtensionOptions } from '../../extensions'; import { TemplateParameterSchema } from '../../../types'; -import { createAsyncValidators } from './createAsyncValidators'; +import { + createAsyncValidators, + type FormValidation, +} from './createAsyncValidators'; import { ReviewState, type ReviewStateProps } from '../ReviewState'; import { useTemplateSchema } from '../../hooks/useTemplateSchema'; import validator from '@rjsf/validator-ajv8'; @@ -93,9 +96,7 @@ export const Stepper = (stepperProps: StepperProps) => { const [activeStep, setActiveStep] = useState(0); const [formState, setFormState] = useFormDataFromQuery(props.initialState); - const [errors, setErrors] = useState< - undefined | Record - >(); + const [errors, setErrors] = useState(); const styles = useStyles(); const extensions = useMemo(() => { diff --git a/plugins/scaffolder-react/src/next/components/Stepper/createAsyncValidators.ts b/plugins/scaffolder-react/src/next/components/Stepper/createAsyncValidators.ts index 299a2451ce..92774084b2 100644 --- a/plugins/scaffolder-react/src/next/components/Stepper/createAsyncValidators.ts +++ b/plugins/scaffolder-react/src/next/components/Stepper/createAsyncValidators.ts @@ -25,7 +25,10 @@ function isObject(value: JsonValue | undefined): value is JsonObject { return typeof value === 'object' && value !== null && !Array.isArray(value); } -type FormValidation = Record< +/** + * @internal + */ +export type FormValidation = Record< string, FieldValidation | Record >; @@ -41,7 +44,7 @@ export const createAsyncValidators = ( formData: JsonObject, pathPrefix: string = '#', current: JsonObject = formData, - ): Promise> { + ): Promise { const parsedSchema = new JSONSchema(rootSchema); const formValidation: FormValidation = {}; @@ -68,7 +71,7 @@ export const createAsyncValidators = ( } } - return formValidation as Record; + return formValidation; } return async (formData: JsonObject) => { diff --git a/plugins/scaffolder-react/src/next/components/Stepper/guards.ts b/plugins/scaffolder-react/src/next/components/Stepper/guards.ts index c7afeb5664..647dc89af7 100644 --- a/plugins/scaffolder-react/src/next/components/Stepper/guards.ts +++ b/plugins/scaffolder-react/src/next/components/Stepper/guards.ts @@ -14,12 +14,13 @@ * limitations under the License. */ import type { FieldValidation } from '@rjsf/utils'; +import { FormValidation } from './createAsyncValidators'; function isFieldValidation(error: any): error is FieldValidation { return !!error && '__errors' in error; } -export function hasErrors(errors?: Record): boolean { +export function hasErrors(errors?: FormValidation): boolean { if (!errors) { return false; } From 18fd02197927f627c61ee715f53fae3cc127225b Mon Sep 17 00:00:00 2001 From: Paul Cowan Date: Mon, 6 Feb 2023 14:57:25 +0000 Subject: [PATCH 5/8] refactor types Signed-off-by: Paul Cowan --- .../scaffolder-react/src/next/components/Stepper/Stepper.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx index eb30329cc6..a1ad289a1a 100644 --- a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx +++ b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx @@ -23,7 +23,7 @@ import { makeStyles, } from '@material-ui/core'; import { type IChangeEvent, withTheme } from '@rjsf/core-v5'; -import { ErrorSchema, FieldValidation } from '@rjsf/utils'; +import { ErrorSchema } from '@rjsf/utils'; import React, { useCallback, useMemo, useState, type ReactNode } from 'react'; import { NextFieldExtensionOptions } from '../../extensions'; import { TemplateParameterSchema } from '../../../types'; From 1b3a9bc042e39b45d8fb11361e4c5434c87c60a4 Mon Sep 17 00:00:00 2001 From: Paul Cowan Date: Mon, 6 Feb 2023 15:54:32 +0000 Subject: [PATCH 6/8] fix changeset Signed-off-by: Paul Cowan --- .changeset/shy-buses-mix.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/shy-buses-mix.md b/.changeset/shy-buses-mix.md index 868da1cc93..c92b119664 100644 --- a/.changeset/shy-buses-mix.md +++ b/.changeset/shy-buses-mix.md @@ -2,4 +2,4 @@ '@backstage/plugin-scaffolder-react': minor --- -refactor createAsyncValidators to be recursive +refactor `createAsyncValidators` to be recursive From 79dac4ac1e9ba684ee4a61388bc950b9f4820ab9 Mon Sep 17 00:00:00 2001 From: Paul Cowan Date: Mon, 6 Feb 2023 16:44:37 +0000 Subject: [PATCH 7/8] rename guards to utils Signed-off-by: Paul Cowan --- .changeset/shy-buses-mix.md | 2 +- .../scaffolder/customScaffolderExtensions.tsx | 4 ++-- .../src/next/components/Stepper/Stepper.tsx | 2 +- .../next/components/Stepper/createAsyncValidators.ts | 12 ++++-------- .../next/components/Stepper/{guards.ts => utils.ts} | 0 5 files changed, 8 insertions(+), 12 deletions(-) rename plugins/scaffolder-react/src/next/components/Stepper/{guards.ts => utils.ts} (100%) diff --git a/.changeset/shy-buses-mix.md b/.changeset/shy-buses-mix.md index c92b119664..b7db90d03c 100644 --- a/.changeset/shy-buses-mix.md +++ b/.changeset/shy-buses-mix.md @@ -2,4 +2,4 @@ '@backstage/plugin-scaffolder-react': minor --- -refactor `createAsyncValidators` to be recursive +refactor `createAsyncValidators` to be recursive to ensure validators are called in nested schemas. diff --git a/packages/app/src/components/scaffolder/customScaffolderExtensions.tsx b/packages/app/src/components/scaffolder/customScaffolderExtensions.tsx index fc37ff9e96..c521bcf86c 100644 --- a/packages/app/src/components/scaffolder/customScaffolderExtensions.tsx +++ b/packages/app/src/components/scaffolder/customScaffolderExtensions.tsx @@ -68,7 +68,7 @@ export const LowerCaseValuePickerFieldExtension = scaffolderPlugin.provide( const MockDelayComponent = ( props: NextFieldExtensionComponentProps<{ test?: string }>, ) => { - const { onChange, formData, rawErrors = [] } = props; + const { onChange, formData, rawErrors } = props; return ( onChange({ test: value })} margin="normal" - error={rawErrors.length > 0 && !formData} + error={rawErrors?.length > 0 && !formData} /> ); }; diff --git a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx index a1ad289a1a..9242ef0086 100644 --- a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx +++ b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx @@ -38,7 +38,7 @@ import { useFormDataFromQuery } from '../../hooks'; import { FormProps } from '../../types'; import { LayoutOptions } from '../../../layouts'; import { useTransformSchemaToProps } from '../../hooks/useTransformSchemaToProps'; -import { hasErrors } from './guards'; +import { hasErrors } from './utils'; const useStyles = makeStyles(theme => ({ backButton: { diff --git a/plugins/scaffolder-react/src/next/components/Stepper/createAsyncValidators.ts b/plugins/scaffolder-react/src/next/components/Stepper/createAsyncValidators.ts index 92774084b2..eec1f368c6 100644 --- a/plugins/scaffolder-react/src/next/components/Stepper/createAsyncValidators.ts +++ b/plugins/scaffolder-react/src/next/components/Stepper/createAsyncValidators.ts @@ -28,10 +28,9 @@ function isObject(value: JsonValue | undefined): value is JsonObject { /** * @internal */ -export type FormValidation = Record< - string, - FieldValidation | Record ->; +export type FormValidation = { + [name: string]: FieldValidation | FormValidation; +}; export const createAsyncValidators = ( rootSchema: JsonObject, @@ -64,10 +63,7 @@ export const createAsyncValidators = ( formValidation[key] = fieldValidation; } } else if (isObject(value)) { - formValidation[key] = (await validate(formData, path, value)) as Record< - string, - FieldValidation - >; + formValidation[key] = await validate(formData, path, value); } } diff --git a/plugins/scaffolder-react/src/next/components/Stepper/guards.ts b/plugins/scaffolder-react/src/next/components/Stepper/utils.ts similarity index 100% rename from plugins/scaffolder-react/src/next/components/Stepper/guards.ts rename to plugins/scaffolder-react/src/next/components/Stepper/utils.ts From ba1f98a0385a08709d3e8b5e159a3fb06b04bc98 Mon Sep 17 00:00:00 2001 From: Paul Cowan Date: Mon, 6 Feb 2023 20:37:22 +0000 Subject: [PATCH 8/8] move isObject into utils Signed-off-by: Paul Cowan --- .../src/next/components/Stepper/createAsyncValidators.ts | 7 ++----- .../scaffolder-react/src/next/components/Stepper/utils.ts | 5 +++++ 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/plugins/scaffolder-react/src/next/components/Stepper/createAsyncValidators.ts b/plugins/scaffolder-react/src/next/components/Stepper/createAsyncValidators.ts index eec1f368c6..86b7619b34 100644 --- a/plugins/scaffolder-react/src/next/components/Stepper/createAsyncValidators.ts +++ b/plugins/scaffolder-react/src/next/components/Stepper/createAsyncValidators.ts @@ -15,15 +15,12 @@ */ import { FieldValidation } from '@rjsf/utils'; -import type { JsonObject, JsonValue } from '@backstage/types'; +import type { JsonObject } from '@backstage/types'; import { ApiHolder } from '@backstage/core-plugin-api'; import { Draft07 as JSONSchema } from 'json-schema-library'; import { createFieldValidation } from '../../lib'; import { NextCustomFieldValidator } from '../../extensions'; - -function isObject(value: JsonValue | undefined): value is JsonObject { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} +import { isObject } from './utils'; /** * @internal diff --git a/plugins/scaffolder-react/src/next/components/Stepper/utils.ts b/plugins/scaffolder-react/src/next/components/Stepper/utils.ts index 647dc89af7..e4da5882e5 100644 --- a/plugins/scaffolder-react/src/next/components/Stepper/utils.ts +++ b/plugins/scaffolder-react/src/next/components/Stepper/utils.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import type { JsonObject, JsonValue } from '@backstage/types'; import type { FieldValidation } from '@rjsf/utils'; import { FormValidation } from './createAsyncValidators'; @@ -35,3 +36,7 @@ export function hasErrors(errors?: FormValidation): boolean { return false; } + +export function isObject(value: JsonValue | undefined): value is JsonObject { + return typeof value === 'object' && value !== null && !Array.isArray(value); +}