Array of "oneOf" cannot be empty

{
  type: 'object',
  properties: {
    ...NodeNameSchema,
    ...NodeImageSchema,
    connections: {
      minItems: 0,
      oneOf: [
        {
          $ref: '#/definitions/direct',
        },
        {
          $ref: '#/definitions/internal',
        },
        {
          $ref: '#/definitions/external',
        },
      ],
    },
  },
  definitions: {
    direct: {
      title: 'Direct Children',
      type: 'array',
      minItems: 0,
      items: {
        type: 'string',
        enum: ["none"],
        default: "none"
      },
    },
    internal: {
      title: 'Internal Buttons',
      type: 'array',
      minItems: 0,
      items: {
        type: 'object',
        properties: {
          label: {
            title: 'Label',
            type: 'string',
          },
          target: {
            title: 'Target',
            type: 'string',
            enum: ["none"],
          },
          variant: {
            title: 'Variant',
            type: 'string',
            default: "default",
            enum: ["default", "secondary"],
          },
        },
        required: ['label', 'target', 'variant'],
      },
    },
    external: {
      title: 'External Buttons',
      type: 'array',
      minItems: 0,
      items: {
        type: 'object',
        properties: {
          label: {
            title: 'Label',
            type: 'string',
          },
          url: {
            title: 'URL',
            type: 'string',
            format: 'uri',
          },
          variant: {
            title: 'Variant',
            type: 'string',
            default: "default",
            enum: ["default", "secondary"],
          },
        },
        required: ['label', 'url', 'variant'],
      },
    },
  },
}

On the “connections” property, I have 3 possibilities:

  • direct
  • internal
  • external
    All of these are connections, which can be an empty array. The thing is, I cannot seem to find a way to allow this behaviour to happen. The only way to “fix” validation is to, on my custom control, on changing tab or delete the last item from the array, run handleChange(path, undefine) to remove connections property from data object.

With no connections property:
(see image top-left corner)

With empty connections property:
(see image top-left corner)

Hi @rjdmacedo ,
I cannot reproduce this in the latest version of the JSON Forms React Seed when using your schema and no UI Schema.
There, no validation is shown for either an initial data object with an empty connections array or after deleting the last element with no additional calls of handleChange.

Which renderer set and version of JSON Forms are you using?

Best regards,
Lucas

I’ve only noticed that I had some object spreading above, apologies for that.
Here is the full schema below.

{
  "type": "object",
  "properties": {
    "name": {
      "type": "string",
      "title": "Name"
    },
    "image": {
      "type": "string",
      "title": "Image",
      "format": "data-url"
    },
    "connections": {
      "oneOf": [
        {
          "$ref": "#/definitions/direct"
        },
        {
          "$ref": "#/definitions/internal"
        },
        {
          "$ref": "#/definitions/external"
        }
      ]
    }
  },
  "required": [
    "name"
  ],
  "definitions": {
    "direct": {
      "title": "Direct Children",
      "type": "array",
      "items": {
        "type": "string",
        "enum": [
          "device-4bitlfn"
        ],
        "default": "none"
      }
    },
    "internal": {
      "title": "Internal Buttons",
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "label": {
            "title": "Label",
            "type": "string"
          },
          "target": {
            "title": "Target",
            "type": "string",
            "enum": [
              "device-4bitlfn"
            ]
          },
          "variant": {
            "title": "Variant",
            "type": "string",
            "default": "default",
            "enum": [
              "default",
              "secondary"
            ]
          }
        },
        "required": [
          "label",
          "target",
          "variant"
        ]
      }
    },
    "external": {
      "title": "External Buttons",
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "label": {
            "title": "Label",
            "type": "string"
          },
          "url": {
            "title": "URL",
            "type": "string",
            "format": "uri"
          },
          "variant": {
            "title": "Variant",
            "type": "string",
            "default": "default",
            "enum": [
              "default",
              "secondary"
            ]
          }
        },
        "required": [
          "label",
          "url",
          "variant"
        ]
      }
    }
  }
}

and here is the UI Schema as well

{
  "type": "VerticalLayout",
  "elements": [
    {
      "type": "Group",
      "label": "Node section",
      "elements": [
        {
          "type": "Control",
          "scope": "#/properties/name"
        },
        {
          "type": "Control",
          "scope": "#/properties/image"
        }
      ]
    },
    {
      "type": "Group",
      "label": "Node connections",
      "elements": [
        {
          "type": "Control",
          "label": "Node connections",
          "scope": "#/properties/connections"
        }
      ]
    }
  ]
}

I’m using 3.5.1. Here is my partial package.json

...
"@jsonforms/core": "^3.5.1",
"@jsonforms/react": "^3.5.1",
"@jsonforms/vanilla-renderers": "^3.5.1",
...

I’m using a control for oneOf, called one-of-control.tsx

import * as _ from 'lodash-es';
import { useCallback, useMemo, useState } from 'react';
import {
  CombinatorRendererProps,
  createCombinatorRenderInfos,
  isOneOfControl,
  RankedTester,
  rankWith,
} from '@jsonforms/core';
import CombinatorProperties from './combinator-properties';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@dcx-dac/ui';
import { JsonFormsDispatch, withJsonFormsOneOfProps } from '@jsonforms/react';
import { TabSwitchConfirmDialog } from './components/tab-switch-confirm-dialog';
import { withVanillaControlProps } from '@jsonforms/vanilla-renderers';

const OneOfControl = ({
  id,
  path,
  data,
  cells,
  schema,
  visible,
  uischema,
  uischemas,
  renderers,
  rootSchema,
  handleChange,
  indexOfFittingSchema,
}: CombinatorRendererProps) => {
  const oneOfRenderInfos = useMemo(
    () => createCombinatorRenderInfos((schema as JsonSchema).oneOf, rootSchema, 'oneOf', uischema, path, uischemas),
    [schema, rootSchema, uischema, path, uischemas]
  );

  const [value, setValue] = useState(oneOfRenderInfos[indexOfFittingSchema || 0].label);
  const [nextValue, setNextValue] = useState('');
  const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);

  const openNewTab = (value: string) => {
    // ❌ Cannot use this code because it will cause validation errors
    // const index = oneOfRenderInfos.findIndex(({ label }) => label === value);
    // const defaultValue = createDefaultValue(oneOfRenderInfos[index].schema, rootSchema);
    // handleChange(path, defaultValue);

    // remove property "path" from data to prevent validation errors
    handleChange(path, undefined);
    setValue(value);
  };

  const handleTabChange = useCallback(
    (value: string) => {
      setNextValue(value);
      if (_.isEmpty(data)) {
        openNewTab(value);
      } else {
        setConfirmDialogOpen(true);
      }
    },
    [data, setConfirmDialogOpen, setNextValue, openNewTab]
  );

  if (!visible) return null;

  return (
    <>
      <CombinatorProperties path={path} schema={schema} rootSchema={rootSchema} combinatorKeyword="oneOf" />
      <Tabs
        value={value}
        onValueChange={handleTabChange}
        defaultValue={oneOfRenderInfos[indexOfFittingSchema || 0].label}
      >
        <div className="w-full flex justify-center">
          <TabsList>
            {oneOfRenderInfos.map(({ label }) => {
              return (
                <TabsTrigger key={`${label.toLowerCase()}-tab-trigger`} value={label}>
                  {label}
                </TabsTrigger>
              );
            })}
          </TabsList>
        </div>
        {oneOfRenderInfos.map(({ label, schema, uischema }) => (
          <TabsContent value={label} key={`${label.toLowerCase()}-tab-content`}>
            <JsonFormsDispatch path={path} cells={cells} schema={schema} uischema={uischema} renderers={renderers} />
          </TabsContent>
        ))}
      </Tabs>
      <TabSwitchConfirmDialog
        id={'oneOf-' + id}
        open={confirmDialogOpen}
        onCancel={() => setConfirmDialogOpen(false)}
        onConfirm={() => {
          openNewTab(nextValue);
          setConfirmDialogOpen(false);
        }}
        onOpenChange={setConfirmDialogOpen}
      />
    </>
  );
};

export const oneOfControlTester: RankedTester = rankWith(3, isOneOfControl);

export default withVanillaControlProps(withJsonFormsOneOfProps(OneOfControl));

and an array-control.tsx

import * as _ from 'lodash-es';
import {
  ArrayControlProps,
  ArrayTranslations,
  composePaths,
  ControlElement,
  createDefaultValue,
  findUISchema,
  Helpers,
  isObjectArrayControl,
  isPrimitiveArrayControl,
  or,
  RankedTester,
  rankWith,
} from '@jsonforms/core';
import { VanillaRendererProps, withVanillaControlProps } from '@jsonforms/vanilla-renderers';
import {
  JsonFormsDispatch,
  withArrayTranslationProps,
  withJsonFormsArrayControlProps,
  withTranslateProps,
} from '@jsonforms/react';
import * as React from 'react';
import { Button, cn, Label, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@dcx-dac/ui';
import { ArrowDown, ArrowUp, PlusIcon, XIcon } from 'lucide-react';

const ArrayControl = ({
  data,
  label,
  path,
  schema,
  errors,
  moveUp,
  addItem,
  enabled,
  moveDown,
  uischema,
  required,
  uischemas,
  renderers,
  rootSchema,
  removeItems,
  handleChange,
}: ArrayControlProps & VanillaRendererProps & { translations: ArrayTranslations }) => {
  const childUiSchema = React.useMemo(
    () => findUISchema(uischemas, schema, uischema.scope, path, 'HorizontalLayout', uischema, rootSchema),
    [uischemas, schema, uischema.scope, path, uischema, rootSchema]
  );

  return (
    <div className="flex flex-col space-y-2">
      <header className="flex justify-between items-center">
        <Label>
          {label}
          {required ? '*' : ''}
        </Label>
        <TooltipProvider>
          <Tooltip>
            <TooltipTrigger asChild>
              <Button
                size="icon"
                className="h-8 w-8 rounded-full"
                onClick={addItem(path, createDefaultValue(schema, rootSchema))}
              >
                <PlusIcon />
              </Button>
            </TooltipTrigger>
            <TooltipContent>
              <p>Add {label.toLowerCase()}</p>
            </TooltipContent>
          </Tooltip>
        </TooltipProvider>
      </header>
      {errors && errors.length > 0 && <p className={cn('h-4 text-[0.8rem] font-medium text-destructive')}>{errors}</p>}

      {!data || !Array.isArray(data) || data.length === 0 ? (
        <p className="text-center text-muted-foreground">No {label.toLowerCase()} added yet.</p>
      ) : (
        data.map((button, index) => {
          const childPath = composePaths(path, `${index}`);

          return (
            <div key={`button-${index + 1}`} className="flex items-center space-x-4">
              <JsonFormsDispatch
                key={childPath}
                path={childPath}
                schema={schema}
                uischema={childUiSchema || uischema}
                renderers={renderers}
              />
              <div className="flex">
                <Button
                  size="icon"
                  variant="ghost"
                  disabled={!enabled || index === 0}
                  aria-label="Move up"
                  onClick={moveUp!(path, index)}
                >
                  <ArrowUp />
                </Button>
                <Button
                  size="icon"
                  variant="ghost"
                  disabled={!enabled || index === data.length - 1}
                  aria-label="Move down"
                  onClick={moveDown!(path, index)}
                >
                  <ArrowDown />
                </Button>
                <Button
                  size="icon"
                  variant="ghost"
                  disabled={!enabled}
                  aria-label="Remove"
                  onClick={() => {
                    if (window.confirm('Are you sure you wish to delete this item?')) {
                      removeItems!(path, [index])();
                      // ---------- here is where I have to remove the "connections" property ----------
                      // if there is only one item left, remove the property from the data
                      if (data.length === 1) {
                        handleChange(path, undefined);
                      }
                    }
                  }}
                >
                  <XIcon />
                </Button>
              </div>
            </div>
          );
        })
      )}
    </div>
  );
};

export const ArrayControlRenderer = ({
  schema,
  uischema,
  data,
  path,
  rootSchema,
  uischemas,
  addItem,
  getStyle,
  getStyleAsClassName,
  removeItems,
  moveUp,
  moveDown,
  id,
  visible,
  enabled,
  errors,
  translations,
  arraySchema,
  ...rest
}: ArrayControlProps & VanillaRendererProps & { translations: ArrayTranslations }) => {
  const controlElement = uischema as ControlElement;
  const labelDescription = Helpers.createLabelDescriptionFrom(controlElement, schema);
  const label = labelDescription.show ? labelDescription.text : '';
  const controlClassName = `control ${Helpers.convertToValidClassName(controlElement.scope)}`;
  const fieldSetClassName = getStyleAsClassName?.('array.layout');
  const buttonClassName = getStyleAsClassName?.('array.button');
  const childrenClassName = getStyleAsClassName?.('array.children');
  const classNames: { [className: string]: string } = {
    wrapper: controlClassName,
    fieldSet: fieldSetClassName,
    button: buttonClassName,
    children: childrenClassName,
  };

  return (
    <ArrayControl
      classNames={classNames}
      data={data}
      label={label}
      path={path}
      schema={schema}
      arraySchema={arraySchema}
      errors={errors}
      addItem={addItem}
      removeItems={removeItems}
      moveUp={moveUp}
      moveDown={moveDown}
      uischema={uischema}
      uischemas={uischemas}
      getStyleAsClassName={getStyleAsClassName}
      rootSchema={rootSchema}
      id={id}
      visible={visible}
      enabled={enabled}
      getStyle={getStyle}
      translations={translations}
      {...rest}
    />
  );
};

export const arrayControlTester: RankedTester = rankWith(1000, or(isObjectArrayControl, isPrimitiveArrayControl));

export default withVanillaControlProps(
  withJsonFormsArrayControlProps(withTranslateProps(withArrayTranslationProps(ArrayControlRenderer)))
);

@rjdmacedo,

The error is unrelated to JSON Forms itself. An empty array matches all of the oneOf entries while only one match is allowed in oneOf. This is why you get the error. You can reproduce this with other validators too, e.g. this online validator.

One solution is to use anyOf, then the error would be gone. However it’s obviously less strict. You either need to live with that or define your entries in a way so that only one can ever match (except for the empty case). Obviously you could also restructure your data in other ways, e.g. add some runtime type information next to the array.