Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Creating custom Vuetify renderer help #105

Open
ribrewguy opened this issue Mar 15, 2024 · 1 comment
Open

Creating custom Vuetify renderer help #105

ribrewguy opened this issue Mar 15, 2024 · 1 comment

Comments

@ribrewguy
Copy link

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
Copy link
Contributor

yaffol commented May 14, 2024

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 #110.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants