eclipsesource / jsonforms-vuetify-renderers

https://jsonforms-vuetify-renderers.netlify.app/
Other
25 stars 26 forks source link

Creating custom Vuetify renderer help #105

Open ribrewguy opened 3 months ago

ribrewguy commented 3 months ago

It would be very helpful to understand how to create a custom Vuetify renderer that properly handles custom types. I am trying to create a renderer for a complex object in my application that I typically use with a compound component I wrote for the case. The primary issues I've been facing are below. Any ideas or help would be greatly appreciated as this is currently a blocker for moving forward with this library.

  1. No matter what I put in as a tester, it will not rank my renderer ahead of the built-in vuetify renderers. I've had to remove the built in renderers in order to allow the custom renderer a chance to render the data. Even using tester: scopeEndsWith('amount') directly didn't work until I removed the vuetify renderers. Note that the income.amount scope certainly ends in "amount".
  2. Once I forced the renderer to render, it appears to send the entire income object to the custom component rather than just the amount property. I've verified this by logging the modelValue from within the field itself.
  3. There is a warning that I cannot seem to resolve in the console that begins as follows: [Vue warn]: Invalid prop: type check failed for prop "id". Expected String with value "undefined", got Undefined at <ControlWrapper id=undefined description=undefined errors="" ... > at <MonetaryAmountControlRenderer renderers= Array [ {…} ] cells= Array [] schema=

I have a test bed (test.vue) configured as such:

<script setup lang="ts">
import {
  defaultStyles,
  mergeStyles,
  vuetifyRenderers,
} from '@jsonforms/vue-vuetify';

import { JsonForms } from '@jsonforms/vue';
import { type IncomeStream } from '~/types/income';
import { entry } from '~/forms/renderers/MonetaryAmountRenderer.vue';

// mergeStyles combines all classes from both styles definitions into one
const myStyles = mergeStyles(defaultStyles, { control: { label: 'mylabel' } });

const renderers = Object.freeze([
  // ...vuetifyRenderers,
  // here you can add custom renderers
  entry,
]);

const { salaryFormDataSchema, salaryFormUiSchema } = useIncome().formSchemas;

const data: Ref<IncomeStream> = ref({
  type: 'SALARY',
  name: 'Salary',
  description: 'Monthly salary',
  amount: {
    value: 1000,
    code: 'USD',
  },
  frequency: '',
});
</script>

<template>
  <div>
    <json-forms
      :data="data"
      :renderers="renderers"
      :schema="salaryFormDataSchema"
      :uischema="salaryFormUiSchema"
    />
  </div>
</template>

You'll note that the amount property is a complex object. I have developed a MonetaryAmountField.vue that accepts the amount complex object via the v-model strategy and handles it appropriately. That is well tested at this point.

The json schema for an salaryFormDataSchema that gets passed to the schema attribute evaluates to:

{
  "type": "object",
  "properties": {
    "id": {
      "type": "string"
    },
    "type": {
      "type": "string",
      "const": "SALARY"
    },
    "name": {
      "type": "string"
    },
    "source": {
      "type": "string"
    },
    "owner": {
      "type": "string"
    },
    "description": {
      "type": "string"
    },
    "createdAt": {
      "allOf": [
        {
          "type": "string"
        },
        {
          "type": "string",
          "format": "date-time"
        }
      ]
    },
    "updatedAt": {
      "allOf": [
        {
          "type": "string"
        },
        {
          "type": "string",
          "format": "date-time"
        }
      ]
    },
    "frequency": {
      "type": "string",
    },
    "amount": {
      "$ref": "#/definitions/amountSchema"
    }
  },
  "required": [
    "type",
    "name",
    "amount"
  ],
  "additionalProperties": false,
  "definitions": {
    "amountSchema": {
      "type": "object",
      "properties": {
        "value": {
          "type": "number",
          "exclusiveMinimum": 0
        },
        "code": {
          "type": "string",
          "default": "USD"
        }
      },
      "required": [
        "value"
      ],
      "additionalProperties": false
    }
  },
  "$schema": "http://json-schema.org/draft-07/schema#"
}

The value for salaryFormUiSchema evaluates to

{
  type: 'VerticalLayout',
  elements: [
    {
      type: 'Control',
      scope: '#/properties/name',
    },
    {
      type: 'Control',
      scope: '#/properties/description',
    },
    {
      type: 'Control',
      scope: '#/properties/amount',
      options: {
        placeholder: 'Enter your continent',
        format: 'monetary-amount',
      },
    },
  ],
}

I attempted to reverse engineer one of the existing renderers and so have wrapped my custom MonetaryAmountField in the ControlWrapper as follows:

<template>
  <control-wrapper
    v-bind="controlWrapper"
    :styles="styles"
    :isFocused="isFocused"
    :appliedOptions="appliedOptions"
  >
    <MonetaryAmountField
      :id="control.id + '-input'"
      :class="styles.control.input"
      :disabled="!control.enabled"
      :autofocus="appliedOptions.focus"
      :placeholder="appliedOptions.placeholder"
      :label="computedLabel"
      :hint="control.description"
      :persistent-hint="persistentHint()"
      :required="control.required"
      :error-messages="control.errors"
      :model-value="control.data"
      :maxlength="
        appliedOptions.restrict ? control.schema.maxLength : undefined
      "
      :size="
        appliedOptions.trim && control.schema.maxLength !== undefined
          ? control.schema.maxLength
          : undefined
      "
      v-bind="vuetifyProps('monetary-amount-field')"
      @update:model-value="onChange"
      @focus="isFocused = true"
      @blur="isFocused = false"
    />
  </control-wrapper>
</template>

<script lang="ts">
import {
  type ControlElement,
  type JsonFormsRendererRegistryEntry,
  rankWith,
  isStringControl,
  and,
  formatIs,
  scopeEndsWith,
  isObjectControl,
} from '@jsonforms/core';
import { defineComponent, ref } from 'vue';
import {
  type RendererProps,
  rendererProps,
  useJsonFormsControl,
} from '@jsonforms/vue';
import { ControlWrapper } from '@jsonforms/vue-vuetify';
import { useVuetifyControl } from '@jsonforms/vue-vuetify';
import MonetaryAmountField from '~/components/MonetaryAmountField.vue';

const controlRenderer = defineComponent({
  name: 'monetary-amount-control-renderer',
  components: {
    ControlWrapper,
    MonetaryAmountField,
  },
  props: {
    ...rendererProps<ControlElement>(),
  },
  setup(props: RendererProps<ControlElement>) {
    console.log('monetary-amount-control-renderer', props);
    return {
      ...useVuetifyControl(
        useJsonFormsControl(props),
        (value) => value || undefined,
        300,
      ),
    };
  },
});

export default controlRenderer;

export const entry: JsonFormsRendererRegistryEntry = {
  renderer: controlRenderer,
  tester: scopeEndsWith('amount'),
};
</script>

For completeness, here is the MonetaryAmountField file:

<script setup lang="ts">
import type { MonetaryAmount } from '~/types/common';

type Props = {
  modelValue?: MonetaryAmount;
  supportedCurrencyCodes?: string[];
};

const props = withDefaults(defineProps<Props>(), {
  supportedCurrencyCodes: () => ['USD'],
  modelValue: () => ({
    value: 0,
    code: 'USD',
  }),
});

const emit = defineEmits(['update:modelValue']);

console.log('MonetaryAmountField', JSON.stringify(props.modelValue, null, 2));

const amount: Ref<MonetaryAmount> = ref(unref(props.modelValue));

watch(amount.value,
  (value) => {
    emit('update:modelValue', value);
  },
);
</script>

<template>
  <v-text-field
    v-model.number="amount.value"
    type="number"
  >
    <template #append>
      <v-select
        v-model="amount.code"
        :items="supportedCurrencyCodes"
        hide-details
      />
    </template>
  </v-text-field>
</template>
yaffol commented 1 month ago

Hi @ribrewguy

I think the problem is that you're defining the JSON Forms renderer entry correctly, but not passing a specific rank to it. I think this means that when your tester matches, you'll get a return value of 1 (I'm assuming this is the default), and therefore your custom renderer entry won't take precedence over the off-the-shelf renderers, unless you remove them as demonstrated above.

What I normally do is use the rankWith helper to pass a specific value to a matching entry, for example (from the stock EnumControlRenderer.vue)

export const entry: JsonFormsRendererRegistryEntry = {
  renderer: controlRenderer,
  tester: rankWith(2, isEnumControl),
};

You can find the rank value of the off-the-shelf renderer you want to override and make sure your custom renderer returns higher than that, or an arbitrarily high value if you 'always' want it to win.

Or, if you want to make sure that a specific tester always ranks higher than a specific other tester (I use this to override off-the-shelf renderers) you can use the withIncreasedRank helper

export const StringControlRendererEntry = {
  renderer: StringControlRenderer,
  tester: withIncreasedRank(1, UpstreamStringControlRendererEntry.tester),
};

NB the off-the-shelf renderer testers aren't currently exported from the released version of @jsonforms/vue-vueitfy, so I've released a version of my fork that does that, and I have a (build failing around the example app - needs work - contributions welcome!) PR to add them https://github.com/eclipsesource/jsonforms-vuetify-renderers/pull/110.