Skip to content

Builder for highly reactive forms following a bring-your-components approach. Based on Vue reactivity, full TypeScript support

License

Notifications You must be signed in to change notification settings

dreamonkey/vue-lx-forms

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

9 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Vue LX Forms

It reads as "Vue Helix Forms", named after the DNA helix from which we borrow some concepts.

We start from a declarative configuration (the DNA helix, mapping genetic instructions to biological "features") of generic "descriptors" (the nucleobases), then collect user defined bindings with components (the complementary bases) meant to render them.
lx-resolver component (acting as the RNA primer) accepts the configuration as input and, sewing descriptors and components together, render the form (which represent the "biological result" encoded into the DNA).
The user can mutate the internal state via form fields (environment-induced DNA mutations), the configuration will then adapt to these changes and show/hide form components accordingly.

Technically speaking, this is a form builder following a bring-your-components approach, but is flexible and extensible enough to render any kind of state driven component tree. It shines when used for complex fields configurations with many business rules interconnecting fields visibility with the underlying state, while it may be overkill for simpler scenarios.

The whole system is strongly typed.

Installation

$ yarn add @dreamonkey/vue-lx-forms
import LxForms, { Binding } from '@dreamonkey/vue-lx-forms';

const bindings: Binding[] = [
  // ... bindings!
];

// Vue CLI/Vite project
import { createApp } from 'vue';

const app = createApp({});

app.use(LxForms, bindings);

// Quasar CLI project (using boot files)
import { boot } from 'quasar/wrappers';

export default boot(({ app }) => {
  app.use(LxForms, bindings);
});

Usage

Here's a guide showing how you can use the whole system.

Define a descriptor type

Even if you can use strings too, we encurage you to use enums when possible as it helps to better manage namespaces in case you need to use the system for multiple fields sets, especially if they share components or descriptors.

// models.ts
export enum OrdersDescriptorType {
  Text = 'Text',
}

Define and register the descriptor interface (TS-only)

// descriptors.ts
import { SimpleDescriptor } from '@dreamonkey/vue-lx-forms';
import { OrdersDescriptorType } from './models';

export type TextDescriptor = SimpleDescriptor<
  OrdersDescriptorType.Text,
  string
>;

declare module '@dreamonkey/vue-lx-forms' {
  interface DescriptorMap {
    [OrdersDescriptorType.Text]: TextDescriptor;
  }
}

Create the component

Each descriptor type must have exactly one component registered to render it, except when using descriptor.component override option. Viceversa, a component may be used to render multiple descriptors types. Note that a single descriptor can be shared by multiple descriptor types too.

<!-- text.vue -->
<script lang="ts">
import {
  extractDescriptorModel,
  getDescriptorProps,
} from '@dreamonkey/vue-lx-forms';
import { defineComponent } from 'vue';
import { TextDescriptor } from './descriptors';

export default defineComponent({
  name: 'TextField',
  inheritAttrs: false,
  props: getDescriptorProps<TextDescriptor>(),
  setup(props) {
    // Never use `descriptor.model` property directly, extract it using `extractDescriptorModel` helper
    const model = extractDescriptorModel(props.descriptor);
    return { model };
  },
});
</script>

<template>
  <label>
    {{ descriptor.label }}
    <input v-model="model" type="text" v-bind="$attrs" />
  </label>
</template>

Bind a descriptor type to a component component

// bindings.ts
import { registerDescriptor, Binding } from '@dreamonkey/vue-lx-forms';
import { OrdersDescriptorType } from './models';
import TextField from './text.vue';

export const binding: Binding = {
  type: OrdersDescriptorType.Text,
  component: TextField,
};

// You can skip this if you provide all bindings
// as the second parameter of `app.use(LxForms, bindings)`
registerDescriptor(binding);

Define the configuration

Provide the initial state and the descriptor list, you'll obtain the configuration, its related result object, as well as the inner reactive state in case you need to tamper with it from outside the system.

// configuration.ts
import { createDescriptor, useLxForms } from '@dreamonkey/vue-lx-forms';
import { FormFieldType } from './models';

// You must define all properties which will be used, even if set to undefined,
// to let the system know it needs to generate a matching ref for them
const orderInitialData = {
  id: 1,
  username: 'XXXX-000',
  food: undefined,
  details: undefined,
};

const { configuration, result, state } = useLxForms(
  orderInitialData,
  // Every property of "stateRefs" contains a ref initialized with the matching property of the initial state
  (stateRefs) => [
    createDescriptor({
      type: FormFieldType.Text,
      model: stateRefs.username,
      label: 'Insert your username',
    }),
    createDescriptor({
      type: FormFieldType.Text,
      model: stateRefs.food,
      label: 'What do you want to eat?',
    }),
    // Only show the "details" when the "food" is initialized
    createConditional(
      () => stateRefs.food.value !== undefined,
      createDescriptor({
        type: FormFieldType.Text,
        model: stateRefs.details,
        label: 'Any details for the cook?',
      })
    ),
  ]
);

// Note that the result computed ref will only contain matching properties for used descriptors,
// while state is a reactive object containing all properties regardless of the current configuration
// >> result.value => { username: 'XXXX-000', food: undefined }
// >> state => { id: 1, username: 'XXXX-000', food: undefined, details: undefined }

// You can use "state" to manually tamper with the underlying data from outside the system
state.food = 'Lasagna';
// "details" is now available, since "food" is defined
// >> result.value => { username: 'XXXX-000', food: 'Lasagna', details: undefined }
state.details = 'No cheese please';
// >> result.value => { username: 'XXXX-000', food: 'Lasagna', details: 'No cheese please' }
state.food = undefined;
// "details" is now not available, since "food" is undefined, even tho its previously set value is retained
// >> result.value => { username: 'XXXX-000', food: undefined }
state.food = 'Pasta alla carbonara';
// "details" is now available again, since "food" is defined, and it retained its previously set value
// >> result.value => { username: 'XXXX-000', food: 'Pasta alla carbonara', details: 'No cheese please' }

export const { ordersFields: configuration, order: result };

Render fields and use the result

<!-- form.vue -->
<script lang="ts">
import { defineComponent } from 'vue';
import { ordersFields, order } from './configuration';

export default defineComponent({
  name: 'OrderForm',
  setup(props) {
    function logOrder() {
      console.log(order.value);
    }

    return { ordersFields, logOrder };
  },
});
</script>

<template>
  <form @submit="logOrder">
    <lx-resolver
      v-for="descriptor in ordersFields"
      :key="descriptor.id"
      :descriptor="descriptor"
    />

    <input type="submit" value="Send order" />
  </form>
</template>

Core concepts

Descriptors

Descriptors are the building blocks of the whole system. Ideally, each descriptor should hold all information bits and GUI-independent code which will later be needed by a component when rendering it as part of the whole form.

Ideally, a descriptor should not care about the GUI-related code and stick to higher level abstractions, as updating bindings to use different sets of components should result in different GUIs without needing changes to the descriptors.

At its bare minimul, each descriptor must have:

  • an id, used by Vue to distinguish between LxResolver instances, which is automatically filled in when using createDescriptor;
  • a type, used by LxResolver to decide which component to render, which can be a simple string, an enum or even a symbol;
  • a label, since almost all fields of a form always have a title or label of some kind;
  • a model, which must be a reactive ref, even if initialized to undefined.

You can also provide a custom component option to override/manually specify which component should be used to render the descriptor.

You should bind descriptor types to a component using registerDescriptor, registerDescriptors or the second argument of the plugin installation function.

const binding: Binding = {
  type: 'text',
  component: TextField,
};

const bindings: Binding[] = [
  {
    type: 'select',
    component: SelectField,
  },
  {
    type: 'checkbox',
    component: CheckboxField,
  },
];

// Register a single descriptor
registerDescriptor(binding);

// Register multiple descriptors
registerDescriptors(bindings);

// Register multiple descriptors when installing the plugin
app.use(LxForms, bindings);

It's fine to have multiple descriptors types bound to a single component, provided that it's able to manage all of them correctly. Eg. text, textarea and password descriptor types can usually be managed by the same component.

const binding: Binding = {
  type: ['text', 'textarea', 'password'],
  component: TextLikeField,
};

registerDescriptor(binding);

If you find yourself in need to create descriptors dynamically, share the same descriptor options between multiple instances, or define them at a time where the underlying reactive state doesn't exist yet, you can use descriptor factories patter. This pattern consist into wrapping the descriptor creation code into a wrapper function (the factory) which will then accept an object containing state refs later on, to create the actual instance of the descriptor.

import {
  createDescriptor,
  DescriptorFactoryFn,
} from '@dreamonkey/vue-lx-forms';

const initialState = {
  username: undefined,
  food: undefined,
};

const coldDescriptorList: DescriptorFactoryFn[] = [
  (stateRefs) => {
    return createDescriptor({
      type: 'text',
      label: 'Insert username',
      model: stateRefs.username,
    });
  },
  (stateRefs) => {
    return createDescriptor({
      type: 'text',
      label: 'Insert favourite food',
      model: stateRefs.food,
    });
  },
];

const { configuration, result, state } = useLxForms(initialState, (stateRefs) =>
  coldDescriptorList.map((descriptorFactory) => descriptorFactory(modelRefs))
);

TypeScript support

You define a new descriptor interface by extending BaseDescriptor, providing an unique type value and the type of the model used by the descriptor. All additional properties are considered type-related options.

interface SelectDescriptor
  // Fields rendered by this descriptor know the model must be read ad written as a string or undefined (the latter is implicit, all models can be undefined)
  extends BaseDescriptor<string> {
  type: 'select';
  lazyOptionsFn: () => Promise<string[]>; // Type-related option, will be used by the component to retrieve the select options
}

If your descriptor don't have any type-related option, you can use SimpleDescriptor instead.

type TextDescriptor = SimpleDescriptor<'text', string>;

It's perfectly fine to have more than a descriptor type for a single descriptor, as long as all types share the same type-related options.

type TextLikeDescriptor = SimpleDescriptor<
  'text' | 'textarea' | 'password',
  string
>;

If all your descriptors share common options, you can add them augmenting CustomBaseDescriptorProperties interface.

import '@dreamonkey/vue-lx-forms';

declare module '@dreamonkey/vue-lx-forms' {
  interface CustomBaseDescriptorProperties {
    required: boolean; // Every descriptor MUST have this property
    placeholder?: string; // Every descriptor MAY have this property
  }
}

Once you defined all your descriptors interfaces, you'll need to augment DescriptorMap interface to map each descriptor type to its descriptor interface. Once you did this, TypeScript will use type value to provide autocompletion when creating descriptors using createDescriptor and when registering bindings. We hope to be able to automate this step in the future.

import '@dreamonkey/vue-lx-forms';

declare module '@dreamonkey/vue-lx-forms' {
  interface DescriptorMap {
    select: SelectDescriptor;
    text: TextDescriptor;
  }
}

Descriptors interfaces are also useful to provide autocompletion into components, providing them as type parameter to getDescriptorProps, as you can see in next section example.

Components

Since most of a field logic is stored into the descriptor, you can easily switch between different component sets just by changing bindings, but a descriptor is useless without a paired component able to render it.

All components you hook to the system must accept a descriptor prop and, if you use it in any way, extract model property from the descriptor. This last bit should happen outside Vue reactivity system, to avoid uncorrect unwrapping.

Use getDescriptorProps to accomplish the first task. To get proper autocompletion, provide via the type parameter the interfaces of all descriptors that the component is able to manage.

For the latter task, use extractDescriptorModel instead. It accepts a descriptor and returns its model property, correctly extracted outside of Vue reactivity system. This happens since we're accessing a property on a prop (which is a reactive object), and that property is a ref itself. Never use model property directly from descriptor prop (eg. via descriptor.model inside templates), as it simply won't work as you expect, breaking the app.

<!-- text.vue -->
<script lang="ts">
import {
  extractDescriptorModel,
  getDescriptorProps,
} from '@dreamonkey/vue-lx-forms';
import { defineComponent } from 'vue';
import { TextDescriptor, PasswordDescriptor } from './descriptors';

export default defineComponent({
  name: 'TextField',
  props: getDescriptorProps<TextDescriptor | PasswordDescriptor>(),
  setup(props) {
    const model = extractDescriptorModel(props.descriptor);

    // Thanks to the specified descriptors interfaces, `props.descriptor` have autocomplete for
    // all type-related options if you use `type` as discriminant for the union
    if (props.descriptor.type === 'text') {
      // ... text-specific actions
    } else {
      // ... password-specific actions
    }

    return { model };
  },
});
</script>

<template>
  <label>
    {{ descriptor.label }}
    <input v-model="model" type="text" />
  </label>
</template>

To allow props pass-through to nested elements, add inheritAttrs: false to the component and v-bind its $attrs on the input element.

<script lang="ts">
export default defineComponent({
  inheritAttrs: false,
  // ... other options
});
</script>

<template>
  <label>
    {{ descriptor.label }}
    <input v-model="model" type="text" v-bind="$attrs" />
  </label>
</template>

Internal state

Each Descriptor uses a reactive variable to store the data provided by the user, which is actually an hook to property of a reactive shared state object. The shared state is generated from the initial state you provide to useLxForms, thus which properties you define there is important: always initialize optional properties to undefined if you need the system to react to changes on them.

The reactive state is returned by useLxForms as state so you can tamper with it programmatically. Note that ideally state should only be mutated indirectly via models provided to each descriptor, and we only provide it as an escape hatch for complex scenarios. Take care if you find yourself tampering the state directly often, are you're probably using the system in the wrong way.

Since state is a very generic name, we suggest you to always rename it to make it clear of which entity that state is holding data, keeping State suffix to let devs know it's the low level reactive object.

const { state: orderState } = useLxForms(/* ... */);

orderState.food = 'Pizza';

Configuration

useLxForms expects a function as its second parameter, which gets in input an object of refs bound to the internal state and should return an array where each item can recursively be:

  • a descriptor;
  • an array of descriptors;
  • a ref resolving to a descriptor or an array of descriptors.

The configuration returned from useLxForms is a computed which is based on that function, but where all reactive refs along the way are recursively unwrapped and all arrays flattened, to get a flat array of descriptors. This avoids many problems connected with the usage of recursive components and makes it really easy to render the configuration. Since we unwrap all refs and it's executed inside a computed body, the configuration will react to changes in any ref accessed into it.

This allows you to create highly reactive forms, showing or hiding fields or groups of fields depending on the value of either an outside ref or one of the provided state-related refs.

import { createDescriptor, useLxForms } from '@dreamonkey/vue-lx-forms';
import { computed } from 'vue';

const orderInitialData = {
  username: 'XXXX-000',
  food: undefined,
  drink: undefined,
  details: undefined,
};

const { configuration, state } = useLxForms(
  orderInitialData,
  // Every property of "stateRefs" contains a ref initialized with the matching property of the initial state
  (stateRefs) => [
    // Single descriptor
    createDescriptor({
      type: FormFieldType.Text,
      model: stateRefs.username,
      label: 'Insert your username',
    }),
    // Array of descriptors
    [
      createDescriptor({
        type: FormFieldType.Text,
        model: stateRefs.food,
        label: 'What do you want to eat?',
      }),

      // Reactive ref of some kind
      // Equal to "createConditional"
      computed(() =>
        stateRefs.food.value !== undefined
          ? createDescriptor({
              type: FormFieldType.Text,
              model: stateRefs.details,
              label: 'Any details for the cook?',
            })
          : []
      ),
    ],
    // Nested array of descriptors
    [
      [
        createDescriptor({
          type: FormFieldType.Text,
          model: stateRefs.drink,
          label: 'What do you want to drink?',
        }),
      ],
    ],
  ]
);

// >> configuration.value => [
//   { /* username descriptor */ },
//   { /* food descriptor */ },
//   { /* drink descriptor */ },
// ]

state.food = 'Lasagna';

// >> configuration.value => [
//   { /* username descriptor */ },
//   { /* food descriptor */ },
//   { /* details descriptor */ },
//   { /* drink descriptor */ },
// ]

Transformers

We already covered how you can use conditional logic into a configuration to display or hide descriptors, and how you can extract it to helper functions as createConditional.

However, sometimes the conditional logic is strictly related to a descriptor and it would be bothersome or not possible to apply the descriptor and an helper function together all the times. To cover this use case you can use transformers.

A transformer is a function that accepts a descriptor as input and returns either a descriptor, an array of descriptors or a computed ref containing either.

The build-in binary descriptor is a good example of how you can use this feature.

You can register a transformer when registering bindings for a particular descriptor type. createDescriptor will automatically execute it whenever a match is found.

const binding: Binding = {
  type: 'binary',
  component: BinaryField,
  transformer: binaryTransformer,
};

registerDescriptor(binding);

LxResolver

Once you got the whole system set up, and you generated a configuration, you need to render that configuration. LxResolver component does just that: when provided with a descriptor, it resolves the components based on the descriptor type and your bindings, then render it providing the descriptor as prop.

Since the configuration is flat, a simple v-for is what you need to show all fields of the configuration.

<template>
  <lx-resolver
    v-for="descriptor in configuration"
    :key="descriptor.id"
    :descriptor="descriptor"
  />
</template>

Result

Whenever you need to extract the current configuration data, you should use the result computed property provided by useLxForms. You can think of result as a cleaned up version of state, where the data for all unused descriptors is left aside.

Here're the main differences between result and state:

  • result is a computed ref, thus it's readonly and its value should be accessed via result.value, while state is a writable reactive object;
  • result will only contain properties bound to displayed fields, while state contains all properties present in the initial object;

Since result is a very generic name, we suggest you to always rename it to make it clear of which entity you're representing an instance.

const { result: order } = useLxForms(/* ... */);

console.log(order.value); // { food: 'Pizza', ... }

Caveats and pitfalls

Never use a method generating a new descriptor object INSIDE a computed function body It will result in a new descriptor being created every time the computed property re-evaluate and could cause an infinite recursion loop Use createConditional helper instead

About

Builder for highly reactive forms following a bring-your-components approach. Based on Vue reactivity, full TypeScript support

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Sponsor this project

 

Packages

No packages published