@squidlab/nova-vue3-helper
v1.1.0
Published
A Laravel Nova helper for creating your own fields.
Downloads
1
Maintainers
Readme
Nova Vue 3 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>
(whereDehydratedValue
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 usingwithMeta()
method or by additional data appended in thejsonSerialize()
method; - The props of the field: by extending
FieldProps<Field>
(whereField
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)
);