@squidlab/nova-vue3-helper
TypeScript icon, indicating that this package has built-in type declarations

1.1.0 • Public • Published

Nova Vue 3 Helper

nova-vue3-helper

An opinionated helper for creating your customized fields for Laravel Nova 4 with Typescript and Vue 3's Composition API.

With this package you can simplify the development of your custom fields, tools and cards for Laravel Nova 4 while supporting Typescript and Vue 3's Composition API. Fully compatible with the latest Nova 4 features such as Repeater fields, dependent fields, etc.

If you want to speed up even more the implementation of your custom Nova field, also check out our squidlab-it/nova-field-template repository for a production-ready template.

Setup

1. Install

To install this package, execute the following command in the root of your custom field:

yarn add @squidlab/nova-vue3-helper

OR

npm install @squidlab/nova-vue3-helper

2. Declare globals

Create a globals.d.ts in the root of your javascript folder with this content:

import { Nova as NovaType } from '@squidlab/nova-vue3-helper';

declare global {
  const Nova: NovaType;
}

This allows you to use Nova utils such as Nova.request, Nova.visit, Nova's event bus, global variables, etc, in a typed way.

General purpose composables

Some of the provided composables are generic and can be used in tools, fields, filters or cards.


useLocalization

Typescript version of the original useLocalization in Laravel Nova.

const { __ } = useLocalization();

const translatedString = __('counter.increment');

useTheme

Utility, gives you the current theme (light or dark) so that you can update your component accordingly.

const currentTheme = useTheme(); // the current theme ('light' or 'dark')

Creating custom fields

1. Define types

To work with your field with types, you'll have to define three different specific types:

  • The type of the (hydrated) internal value: the type of data your field will handle;
  • The type of the dehydrated value: the type of data that your field will receive from the backend. Usually a string, but it depends on your resolve callbacks of your field, the serialization method (jsonSerialize()) of your field, and also on the casts applied by the model (e.g., if the model is casting the corresponding attribute to a json object);
  • The type of the field: by extending Field<DehydratedValue> (where DehydratedValue is the dehydrated value defined in the previous step), which includes all the other standard values that belong to each field, you can set additional metadata that is sent to the field, for example by using withMeta() method or by additional data appended in the jsonSerialize() method;
  • The props of the field: by extending FieldProps<Field> (where Field is the type of the field defined in the previous step), you can set the type of props that are sent to the vue component of the field. If you're not changing Nova's internal behaviour, there are no easy/common way to add additional custom props, so you usually don't have to add other properties here.

1.1 Example

Let's suppose we implemented a field Counter that acts as a simple counter. Our field saves in the DB the count value as a json object like { "counter": 10 }. In the model we're not casting the value to array, and in our field's resolve() method we're not de-serializing the value, so our Vue field will receive the data as string ("{ \"counter\": 10 }"). Our field defines also two methods allowIncrement(bool $allowed = true) and allowDecrement(bool $allowed = true), which allow us to define if the field should show respectively a "plus" and a "minus" buttons to increment and decrement the counter, by adding meta to the field.

The PHP code of this field could be like:

<?php

namespace SquidLab\Counter;

use Laravel\Nova\Fields\SupportsDependentFields;
use Laravel\Nova\Fields\Field;

class Counter extends Field {
    use SupportsDependentFields;
    
    public $component = 'counter';
    
    public function allowIncrement(bool $allowed = true): static
    {
        return $this->withMeta([
            'allowIncrement' => $allowed,
        ]);
    }
    
    public function allowDecrement(bool $allowed = true): static
    {
        return $this->withMeta([
            'allowDecrement' => $allowed,
        ]);
    }
}

We define those 4 types:

import { Field, FieldProps } from '@squidlab/nova-vue3-helper';

export interface YourValue {
    counter: number;
}

export type YourDehydratedValue = string;

export interface YourField extends Field<YourDehydratedValue> {
    allowIncrement: boolean;
    allowDecrement: boolean;
}

export type YourFieldProps = FieldProps<YourField>;

2. Define field

Our Nova 4 fields are composed by 3 different components:

  • Index component: Used in the index view of the resource
  • Detail component: Used in the details view of the resource
  • Form component: Used in the update/create view of the resource

The purpose of this package is to provide to the three components an easy way to work with:

  • the field's props,
  • the resolved value,
  • the internal value (which can change by the user inputs),
  • the submission of the new value,
  • Nova's events (e.g., for dependent fields changes),

To achieve that, are available to the developers several composables that can be used in your components. Obviously you can choose which are useful and which are not depending on your needs. There are some higher level composables which are meant to be used for common easy fields which are working in a "standard" way. But if you need more granular control you can use the lower level composables (which are the same used by the higher level ones) and build your field as you want.

2.1 Props

To obtain the field props, you can use defineProps from vue by using the props type that you've defined before.

For example:

const props = defineProps<YourFieldProps>();

2.2 Events

To define the emit function needed for emitting specific events used by Nova to handle your field, you can use defineEmits from vue by using the emit type provided in this package.

For example:

import type { FieldEmitFn } from '@squidlab/nova-vue3-helper';

const emit = defineEmits<FieldEmitFn>();

3. High-level Composables

Here's a list of the available composables that are abstractions of several other lower-level composables. They're ready to be used as-is without much tinkering, and are "all-inclusive". 99% of the times you should just use these, but if you're developing something more complex, or you need more granular control, you can use instead the lower-level composables with all the personalization you need.


useFormField

Higher level composable, which uses several other composables described here to simplify the development of "standard" form components.

const {
  value, // the current internal (hydrated) value
  currentField, // current field (synced with changes triggered by dependent fields)
  fieldLabel, // the label of the field
  helpText, // help text to be shown under the field's input
  shouldShowHelpText, // boolean that indicates if the help text has to be shown
  emitValueChange, // call this method when the user is interacting with the input to update the field's internal value
  errors, // array of error messages of this field
  firstError, // the first of the error messages
  hasError, // boolean that indicates if there are errors associated with this field
  shouldShowErrors, // boolean that indicates if the error messages have to be shown
} = useFormField<YourFieldProps, YourField, YourDehydratedValue, YourValue>(
  props, // props of the field component
  // hydrate function, takes the dehydrated field.value and transforms it to the internal value 
  (field) =>
    (JSON.parse(field.value ?? 'null') as YourValue | null) ?? {
      counter: 0,
    },
  // dehydrate function, takes the hydrated value and de-hydrate it before sending it in the FormData of the saving request and in the attribute change event
  (value) => {
    return JSON.stringify(value || {});
  },
  emit, // emit function used to emit component's events
  fieldName, // optional, reference to a string used to override the field label
  showErrors, // optional, reference to a boolean used to indicate if the errors have to be shown or not
  // optional, callback used to fill the FormData of the saving request with additional data
  (formData) => {
    // manipulate formData as you need
  }
);

useDetailField

Higher level composable, which uses several other composables described here to simplify the development of "standard" detail components.

const {
  value, // the current internal (hydrated) value
  fieldLabel, // the label of the field
} = useDetailField<YourFieldProps, YourField, YourDehydratedValue, YourValue>(
  props, // props of the field component
  // hydrate function, takes the dehydrated field.value and transforms it to the internal value 
  (field) =>
    (JSON.parse(field.value ?? 'null') as YourValue | null) ?? {
      counter: 0,
    },
  emit, // emit function used to emit component's events
  fieldName, // optional, reference to a string used to override the field label
);

useIndexField

Higher level composable, which uses several other composables described here to simplify the development of "standard" index components.

const {
  value, // the current internal (hydrated) value
} = useIndexField<YourFieldProps, YourField, YourDehydratedValue, YourValue>(
  props, // props of the field component
  // hydrate function, takes the dehydrated field.value and transforms it to the internal value 
  (field) =>
    (JSON.parse(field.value ?? 'null') as YourValue | null) ?? {
      counter: 0,
    },
);

3.1 Example

Taking the example 1.1 as base, here is an example on how the javascript part of the form component could be implemented:

import { defineProps, ref } from 'vue';
import type { FieldEmitFn } from '@squidlab/nova-vue3-helper';
import { useFormField, useLocalization } from '@squidlab/nova-vue3-helper';
import { config } from '@/config';
import { FieldProps } from '@/types/field-props';
import { Value } from '@/types/value';
import { DehydratedValue } from '@/types/dehydrated-value';
import { Field } from '@/types/field';

const { __ } = useLocalization();

const props = defineProps<FieldProps>();

const emit = defineEmits<FieldEmitFn>();

const {
  value,
  currentField,
  fieldLabel,
  helpText,
  shouldShowHelpText,
  emitValueChange,
  firstError,
  hasError,
  shouldShowErrors,
} = useFormField<FieldProps, Field, DehydratedValue, Value>(
  props,
  // hydrate function
  (field) =>
    (JSON.parse(field.value) as Value | null) ?? {
      counter: 0,
    },
  // de-hydrate function
  (value) => {
    return JSON.stringify(value || {});
  },
  emit
);

const input = ref<HTMLInputElement>();

const getInputValue = () => {
  let str = input.value?.value ?? '0';
  str = str.replaceAll(/[^0-9]+/g, '');
  const num = parseInt(str, 10);
  if (isNaN(num)) {
    return 0;
  }
  return num;
};

const onDecrementClick = () => {
  emitValueChange({
    counter: getInputValue() - 1,
  });
};
const onIncrementClick = () => {
  emitValueChange({
    counter: getInputValue() + 1,
  });
};
const onInputChange = () => {
  emitValueChange({
    counter: getInputValue(),
  });
};

4. Low-level Composables

Here's a list of the low-level (of abstraction) composables. If you're developing something more complex, or you need more granular control, you can use these instead of the "all-inclusive" high-level composables.


useFieldFill

This composable watches the field for changes (for example, if the field is changed by a sync due to a changes of another dependent attribute) and, when needed, sets the fill callback.

useFieldFill<YourField>(
  currentField, // current field (synced with changes triggered by dependent fields)
  // fill method
  (formData) => {
    if (currentField.value.visible) {
        formData.append(
          currentField.value.attribute, // attribute name 
          dehydrate(value.value) // serializes the internal value into the dehydrated value
        );
    }
});

useFieldLabel

Used to obtain the field's label.

const {
  fieldLabel, // the label of the field
} = useFieldLabel<YourField>(
  currentField, // current field (synced with changes triggered by dependent fields)
  fieldName, // optional, reference to a string used to override the field label
);

useFieldHelpText

Used to obtain the field's help text.

const {
  helpText, // help text to be shown under the field's input
  shouldShowHelpText, // boolean that indicates if the help text has to be shown
} = useFieldHelpText<YourField>(
    showHelpText, // reference to a boolean used to indicate if the help text has to be shown or not, usually taken from props.showHelpText
    currentField, // current field (synced with changes triggered by dependent fields)
);

useFieldValidationErrors

Used to obtain the field's errors.

const {
  errors, // array of error messages of this field
  firstError, // the first of the error messages
  hasError, // boolean that indicates if there are errors associated with this field
  shouldShowErrors, // boolean that indicates if the error messages have to be shown
} = useFieldValidationErrors<YourField>(
  errors, // all the errors, usually taken from props.errors
  currentField, // current field (synced with changes triggered by dependent fields)
  showErrors // optional, reference to a boolean used to indicate if the errors have to be shown or not
);

useFieldValue

Used to easily manage the field's internal value.

const formUniqueId = computed(() => props.formUniqueId); // form unique id of the field 

const {
  value, // the current internal (hydrated) value
  emitValueChange, // call this method when the user is interacting with the input to update the field's internal value
} = useFieldValue<YourField, YourValue, YourDehydratedValue>(
    currentField, // current field (synced with changes triggered by dependent fields)
    formUniqueId,
    // hydrate function, takes the dehydrated field.value and transforms it to the internal value 
    (field) =>
        (JSON.parse(field.value ?? 'null') as YourValue | null) ?? {
            counter: 0,
        },
    // dehydrate function, takes the hydrated value and de-hydrate it before sending it in the attribute change event.
    (value) => {
        return JSON.stringify(value || {});
    },
);

useFieldAttributeEventName

Get the attribute's event names used internally in Nova for signaling value changes and for dependent fields syncing.

const attribute = computed(() => currentField.value.attribute); // attribute name
const formUniqueId = computed(() => props.formUniqueId); // form unique id of the field 

const valueEventName = useFieldAttributeEventName(
  attribute,
  'value',
  formUniqueId, 
);
const changeEventName = useFieldAttributeEventName(
  attribute,
  'change',
  formUniqueId,
);

useFieldChangeEvent

The change event is used by other attributes that depend on the current attribute to update their fields accordingly to the value changes.

const attribute = computed(() => currentField.value.attribute); // attribute name
const formUniqueId = computed(() => props.formUniqueId); // form unique id of the field 

const {
  emitFieldValueChange // to be called when the internal value is changed
} = useFieldChangeEvent<YourDehydratedValue>(
  attribute,
  formUniqueId
);

watch(
  () => currentValue.value, // current internal value
  (newValue, oldValue) => {
    if (newValue !== oldValue) {
      emitFieldValueChange(
        dehydrate(newValue) // dehydrate function, takes the hydrated value and de-hydrate it before sending it in the attribute change event.
      );
    }
  }
);

useFieldValueEvent

The value event is used to update the field's internal value.

const attribute = computed(() => currentField.value.attribute); // attribute name
const formUniqueId = computed(() => props.formUniqueId); // form unique id of the field
const currentValue = ref<YourValue|null>(null);

const { emitValueChange } = useFieldValueEvent<YourValue>(
    attribute,
    formUniqueId,
    // optional, callback executed when the event occurs
    (newValue) => {
        currentValue.value = newValue;
    }
);

const onInputChange = (event: Event) => {
    const newValue = parseInt(event.target , 10);
    emitValueChange({
        counter: isNaN(newValue) ? 0 : newValue,
    });
};

useFieldVisibilityEvent

The visibility event is used by panels to indicate if the panel has to be visible or not (if a panel has no visible fields, it's hidden).

const attribute = computed(() => currentField.value.attribute); // attribute name
const visible = computed(() => currentField.value.visible); // current field's visibility

const emit = defineEmits<EmitFn>();

useFieldVisibilityEvent(
  emit,
  attribute,
  visible,
);

useDependentFormField

This composable handles the sync of the field depending on other attributes on the form. In other words, it responds to changes of dependent fields by updating the current field accordingly. It requires several properties that are given directly from the component props.

const {
    field,
    formUniqueId,
    resourceName,
    resourceId,
    viaRelationship,
    viaResource,
    viaResourceId,
    relatedResourceName,
    relatedResourceId,
    syncEndpoint,
} = toRefs(props);

const { 
  currentField // the updated field 
} = useDependentFormField<YourField, YourValue, YourDehydratedValue>(
    value, // current internal value
    initialValue, // initial value of the field
    dehydrate, // dehydrate function, takes the hydrated value and serializes it to be sent in the sync request
    field, // from props
    formUniqueId, // from props
    resourceName, // from props
    resourceId, // from props
    viaRelationship, // from props
    viaResource, // from props
    viaResourceId, // from props
    relatedResourceName, // from props
    relatedResourceId, // from props
    syncEndpoint // from props
);

useFormEditMode

Check if the current form is a create, update, attach, update-attach form, and if it's regarding a pivot table.

const {
    resourceId,
    relatedResourceName,
    relatedResourceId,
} = toRefs(props);

const {
  editMode, // 'create', 'update', 'attach' or 'update-attached' depending on the current form  
  pivot // boolean, true if attaching in a many-to-many relation (if editMode is 'attach' or 'update-attached')
} = useFormEditMode(
    resourceId,
    relatedResourceName,
    relatedResourceId
);

useRepeaterField

This composable handles compatibility with Repeater fields.

const {
  viaParent, // attribute name of the parent Repeater field 
  index, // index of the current row in the Repeater
  nestedValidationKey, // validation key of the field when contained in a Repeater field
  nestedAttribute, // full attribute name, e.g. "repeater_name[0][field_name]"
} = useRepeaterField<YourField>(
  currentField, // current field (synced with changes triggered by dependent fields)
);

Package Sidebar

Install

npm i @squidlab/nova-vue3-helper

Weekly Downloads

1

Version

1.1.0

License

MIT

Unpacked Size

1.23 MB

Total Files

40

Last publish

Collaborators

  • robertonegro