Motyw
Formularze i walidacja
Formularze zbudowane są na bibliotece vee-validate 4 wraz ze schematem yup. Komunikaty walidacji tłumaczone są automatycznie, a błędy 422 zwrócone przez backend przypisywane są do właściwych pól.
Wzorzec trzech plików
| Plik | Odpowiedzialność |
|---|---|
forms/<model>.form.js | Wartości początkowe oraz schemat walidacji, bez części wizualnej |
components/<Moduł><Model>Form.vue | Pola, układ i podpięcie mutacji |
pages/<modele>/<Model>{Create,Edit}Page.vue | Pobranie danych i przekazanie ich do formularza |
Definicja formularza
js
// src/modules/blog/forms/article.form.js
import { array, bool, number, object, string } from "yup";
import { date } from "quasar";
import { getFormFields } from "modules/core/utils/form";
export const articleForm = {
initialValues(currentValues) {
const initialValues = {
title: "",
slug: "",
description: "",
published_at: date.formatDate(Date.now(), "YYYY-MM-DD HH:mm"),
active: false,
translation_active: true,
section_groups: [],
category_id: null,
seo: seoForm.initialValues(),
media_files: getImageCollectionInitialValues("main_picture"),
};
if (currentValues) {
return getFormFields(currentValues, initialValues);
}
return initialValues;
},
validationSchema() {
return object({
title: string().ensure().required().max(255).label("labels.title"),
slug: string().ensure().nullable().max(255).label("labels.slug"),
description: string().ensure().label("labels.description"),
active: bool().label("labels.active"),
translation_active: bool().label("labels.translation_active"),
category_id: number().nullable().label("labels.category"),
section_groups: array().of(getContentBuilderValidationSchema()),
seo: seoForm.validationSchema(),
});
},
};Funkcja getFormFields
js
getFormFields(data, initialValues, fieldsToOmit?)Funkcja wybiera z odpowiedzi API wyłącznie te klucze, które występują w initialValues.
Dlaczego formularz filtruje odpowiedź
Backend zwraca więcej danych, niż potrzebuje formularz, między innymi created_at, relacje oraz liczniki. Bez tego filtra wszystkie one wróciłyby w żądaniu PATCH. Obiekt initialValues stanowi więc jednocześnie kontrakt określający, co formularz wysyła.
Metoda label() przyjmuje klucz i18n, a nie tekst
js
string().required().label("labels.title") // ✔ klucz tłumaczenia
string().required().label("Tytuł") // ✘ zadziała, lecz nie zostanie przetłumaczoneWynika to z pliku boot src/boot/packages/yup.js, który zamienia komunikaty yup na obiekty { key, label }, renderowane następnie przez useErrorMessage().
Komponent formularza
vue
<!-- src/modules/blog/components/BlogArticleForm.vue -->
<script setup>
import { useForm } from "vee-validate";
import { useArticleSaveMutation } from "modules/blog/api/mutations/article.mutations";
import { articleForm } from "modules/blog/forms/article.form";
const props = defineProps({
alias: { type: String, default: "blog.articles" },
currentValues: { type: Object, default: null },
});
const id = computed(() => props.currentValues?.id);
const isNew = computed(() => !id.value);
const { values, handleSubmit, resetForm } = useForm({
initialValues: articleForm.initialValues(props.currentValues),
validationSchema: articleForm.validationSchema(),
});
const mutation = useArticleSaveMutation(id);
const { handleSave, handleSaveAndReturn, handleCancel } = useFormSubmit(mutation, {
alias: props.alias,
handleSubmit,
isNew,
});
watch(
() => props.currentValues,
(currentValues) => {
resetForm({ values: articleForm.initialValues(currentValues) });
},
);
</script>
<template>
<base-form>
<template #base-page-top-right>
<v-status-toggles />
</template>
<base-form-section class="q-mb-lg">
<div class="row q-col-gutter-md">
<v-input class="col-lg-4 col-sm-6 col-12" name="title" :label="$t('labels.title')" />
<v-input-slug class="col-lg-4 col-sm-6 col-12" :source="values.title" />
<v-tiny-editor class="col-12" name="description" :label="$t('labels.description')" />
<v-image-collection :label="$t('labels.main_picture')" class="col-12" />
</div>
</base-form-section>
<base-form-btn-group
:hide-save-and-return="!currentValues"
@submit="handleSave"
@submit-and-return="handleSaveAndReturn"
@cancel="handleCancel"
/>
</base-form>
</template>Obserwator na currentValues jest obowiązkowy
Composable useForm odczytuje initialValues wyłącznie raz. Bez pary watch i resetForm formularz pozostanie pusty w sytuacji, gdy dane z zapytania nadejdą po zamontowaniu komponentu.
Composable useFormSubmit
js
useFormSubmit(mutation, { handleSubmit, alias, isNew, redirectRoute, onSaveRoute })| Opcja | Opis |
|---|---|
handleSubmit | Pochodzi z useForm |
alias | Prefiks tras, na podstawie którego powstają ${alias}.index oraz ${alias}.edit |
isNew | Wartość ref albo logiczna, określająca tworzenie nowego rekordu |
redirectRoute | Nadpisanie trasy dla przycisków anulowania oraz zapisu z powrotem |
onSaveRoute | Nadpisanie trasy po zwykłym zapisie |
Composable zwraca następujące wartości:
| Klucz | Opis |
|---|---|
isSubmitting | Odpowiednik mutation.isPending |
handleSave | Zapis z pozostaniem w formularzu |
handleSaveAndReturn | Zapis wraz z powrotem na listę |
handleCancel | Powrót bez zapisu |
mutation | Przekazana mutacja |
createCustomSubmit(options) | Tworzy własny handler zapisu |
Przebieg zapisu
Wyświetlenie globalnego spinnera Quasara.
Wywołanie
mutation.mutateAsync(values).Po powodzeniu — przekierowanie zależne od przycisku i trybu:
Przycisk Tryb Cel Zapis nowy rekord ${alias}.editz identyfikatorem zresult.model_idZapis nowy rekord, brak model_id${alias}.indexZapis edycja bez przekierowania — użytkownik zostaje na miejscu Zapis z powrotem dowolny ${alias}.indexW razie błędu —
ctx.setErrors(getFormValidationErrors(error)).Ukrycie spinnera, niezależnie od wyniku.
Backend powinien zwracać pole model_id
Na jego podstawie useFormSubmit przenosi użytkownika na ekran edycji po utworzeniu rekordu. Brak tego pola skutkuje przekierowaniem na listę.
Własny zapis
js
// src/modules/blog/components/BlogArticleForm.vue
const { createCustomSubmit } = useFormSubmit(mutation, { handleSubmit, alias, isNew });
const handlePublish = createCustomSubmit({
targetRoute: { name: "blog.articles.index" },
onSuccess: async (result, values) => { /* ... */ },
onError: async (error, values) => { /* pełne przejęcie obsługi błędu */ },
});Opcja onError wyłącza mapowanie błędów na pola
Po jej podaniu wywołanie ctx.setErrors(...) nie następuje, a obsługa błędów 422 przechodzi w całości na stronę wywołującego.
Walidacja i komunikaty
Pochodzenie polskich komunikatów
js
// src/boot/packages/yup.js
required: ({ label }) => ({ key: "validations.required", label }),
min: ({ label, min }) => ({ key: "validations.min string length", label, min }),Biblioteka yup zwraca obiekt, który useErrorMessage() z pliku modules/core/composables/form.js renderuje poprzez i18n, tłumacząc również label:
js
// src/modules/core/composables/form.js
t(value.key, { field: getField(value.label), ...pozostałe })Klucze komunikatów znajdują się w core/trans/{en,pl} pod ścieżką validations.*, co opisują Tłumaczenia.
Błędy 422 z backendu
Funkcja getFormValidationErrors z pliku modules/core/utils/form.js zamienia notację stosowaną przez Laravel na notację vee-validate:
items.0.name → items[0].nameDzięki temu błędy pochodzące z zagnieżdżonych kolekcji, na przykład z content buildera, trafiają do konkretnego pola. W trybie deweloperskim błędy logowane są dodatkowo w konsoli funkcją console.table.
Nieudana walidacja po stronie klienta
Funkcja onFormInvalidSubmit wyświetla toast messages.validation error i loguje szczegóły w konsoli deweloperskiej.
Moment wyświetlenia błędu
Wszystkie komponenty V* korzystają z tego samego warunku:
js
:error="errorMessage && meta.touched ? true : null"Błąd pojawia się dopiero po dotknięciu pola przez użytkownika. Po nieudanym zapisie vee-validate oznacza pola jako dotknięte, więc błędy z API widoczne są natychmiast.
Pola niestandardowe, pozbawione własnego komponentu V*, korzystają z:
vue
<base-error-message name="section_groups[0].sections[0].items[0].content" />Powłoki formularza
BaseForm
Komponent renderuje q-form, przepuszcza atrybuty oraz sloty i wstrzykuje do scope slotów aktualny język treści. Slot base-page-top-right teleportowany jest do nagłówka strony:
vue
<base-form>
<template #base-page-top-right>
<v-status-toggles />
</template>
...
</base-form>BaseFormSection
Grupuje pola w kartę. Przyjmuje propsy title i caption oraz udostępnia slot domyślny i top-right.
BaseFormBtnGroup
Przyklejony pasek umieszczony na dole formularza.
| Prop | Typ | Domyślnie | Opis |
|---|---|---|---|
actions | Array | [] | Dodatkowe przyciski w postaci { name, label, icon, color, order, tooltip, callback, ...propsy q-btn } |
mergeActions | Boolean | true | Decyduje o scalaniu z przyciskami domyślnymi po polu name |
bgColor | String | "white" | Tło paska |
hideCancelConfirmDialog | Boolean | false | Pomija potwierdzenie przy anulowaniu |
hideSaveAndReturn | Boolean | false | Ukrywa przycisk zapisu z powrotem |
Komponent emituje zdarzenia submit, submit-and-return oraz cancel, a także udostępnia sloty prepend i append. Przyciski domyślne to anulowanie z kolejnością 1, zapis z powrotem z kolejnością 1.5 oraz zapis z kolejnością 2.
Ukrywanie przycisku zapisu z powrotem na ekranie tworzenia
vue
<base-form-btn-group :hide-save-and-return="!currentValues" ... />Katalog pól
Wszystkie komponenty V* przyjmują prop name, będący ścieżką pola vee-validate, i przekazują pozostałe atrybuty do opakowanego komponentu Quasara.
| Komponent | Opakowuje | Dodatkowe propsy |
|---|---|---|
VInput | BaseInput → q-input | emituje change |
VInputSlug | VInput | name domyślnie "slug", source — przycisk generuje slug z podanego tekstu |
VSelect | BaseSelect → q-select | — |
VDictionarySelect | BaseDictionarySelect | url*, alias*, queryParams, immediate, searchable, searchField, paginate, perPage, optionValue, defaultOptions, v-model:searchPhrase |
VDictionarySelectTable | BaseDictionarySelectTable | jak wyżej oraz optionLabel, columnLabel, sortable, disable; model tablicowy z dodawaniem wierszy |
VSelectTree | BaseSelectTree | nodes*, labelKey*, fieldAttrs, filterAttrs |
VToggle | BaseToggle | — |
VCheckbox | BaseCheckbox | — |
VStatusToggles | BaseStatusToggles | noTranslations; renderuje przełączniki active oraz translation_active |
VDateTimePicker | BaseDateTimePicker | datePicker, timePicker, defaultMask, defaultInputMask |
VTimePicker | BaseTimePicker | mask, format24h |
VColorPicker | BaseColorPicker | — |
VRatingPicker | BaseRatingPicker | — |
VCommonStatusSelect | BaseCommonStatusSelect | type: "common" dla wartości aktywny/nieaktywny albo "boolean" dla tak/nie |
VTinyEditor | BaseTinyEditor | initConfig, mergeConfig, path |
VEditorSimple | BaseEditorSimple | toolbar, mergeWithDefaultToolbar, definitions |
VContentBuilder | — | mode, opisany w Content builderze |
VSeo | — | name, domyślnie "seo" |
VFileCollection | MediaFileCollection | opisany w sekcji Media |
VImageCollection | — | nameDesktop, nameMobile, stacked |
VMediaUploadField | MediaUploadField | mediaName, uploadName, multiple, accept, size |
VOptionSetting | dynamiczny | name*, type* — komponent dobierany na podstawie typu ustawienia |
Gwiazdką oznaczono propsy wymagane.
Odpowiedniki Base* tych samych pól przyjmują modelValue wraz z error i errorMessage, dzięki czemu działają również poza formularzem.
Przykład: słownik pobierany z API
vue
<!-- src/modules/blog/components/BlogArticleForm.vue -->
<v-dictionary-select
name="category_id"
url="/blog/categories"
alias="blog.categories"
emit-value
map-options
option-value="id"
option-label="title"
search-field="title"
:label="$t('labels.category')"
/>Wyniki przechowywane są w cache Vue Query pod kluczem ["dictionary", alias, params].
Tworzenie własnego komponentu V*
Każdy komponent V* powstaje według tego samego schematu:
vue
<!-- src/modules/core/components/form/VMyField.vue -->
<script setup>
import { useField } from "vee-validate";
const props = defineProps({
name: { type: String, required: true },
});
const emit = defineEmits(["change"]);
const { errorMessage: vErrorMessage, meta, value } = useField(() => props.name);
const errorMessage = useErrorMessage(vErrorMessage);
function onChange(val) {
value.value = val;
emit("change", val);
}
</script>
<template>
<base-input
:model-value="value"
@update:model-value="onChange"
:error="errorMessage && meta.touched ? true : null"
:error-message="errorMessage"
>
<template v-for="(_, slot) of $slots" #[slot]="scope">
<slot :name="slot" v-bind="scope || {}" />
</template>
</base-input>
</template>Wywołanie useField wymaga gettera
Przekazanie samego props.name zamraża ścieżkę pola. W polach wewnątrz kolekcji — na przykład w content builderze — ścieżka zmienia się przy przestawianiu elementów, dlatego zawsze używaj postaci useField(() => props.name).
Synchronizacja pola translation_active
js
// src/modules/blog/components/BlogArticleForm.vue
useTranslationActiveSync(); // pola na najwyższym poziomie
useTranslationActiveSync("translations"); // z prefiksem ścieżkiPrzy jednym skonfigurowanym języku treści composable kopiuje wartość active do translation_active — użytkownik nie musi wtedy przestawiać dwóch przełączników. Korzysta z niego VStatusToggles.
TinyMCE
vue
<v-tiny-editor name="description" :label="$t('labels.description')" bg-color="white" />| Prop | Domyślnie | Opis |
|---|---|---|
label | — | Etykieta pola |
initConfig | {} | Konfiguracja TinyMCE |
mergeConfig | true | Wartość false oznacza użycie initConfig bez scalania z konfiguracją domyślną |
path | "/" | Katalog startowy przy wstawianiu mediów |
error, errorMessage | null | Stan błędu, ustawiany przez VTinyEditor |
Modelem jest łańcuch znaków zawierający kod HTML. Konfiguracja domyślna ustawia język zgodny z bieżącym locale, wysokość 315 px oraz zestaw wtyczek obejmujący listy, odnośniki, obrazy, tabele, podgląd kodu, tryb pełnoekranowy i media.
Przycisk wstawiania obrazu otwiera menedżer mediów, a adres URL buduje useMediaConversion. Obrazy kadrowane otrzymują adres konwersji, pozostałe — adres oryginału, uzupełniony atrybutem alt pochodzącym z pola alt_attribute. Szczegóły opisuje sekcja Media.
Lżejszą alternatywą jest VEditorSimple, czyli q-editor wyposażony w przyciski wstawiania obrazu i odnośnika z biblioteki mediów.