Skip to content

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 ​

PlikOdpowiedzialność
forms/<model>.form.jsWartości początkowe oraz schemat walidacji, bez części wizualnej
components/<Moduł><Model>Form.vuePola, układ i podpięcie mutacji
pages/<modele>/<Model>{Create,Edit}Page.vuePobranie 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łumaczone

Wynika 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 })
OpcjaOpis
handleSubmitPochodzi z useForm
aliasPrefiks tras, na podstawie którego powstają ${alias}.index oraz ${alias}.edit
isNewWartość ref albo logiczna, określająca tworzenie nowego rekordu
redirectRouteNadpisanie trasy dla przycisków anulowania oraz zapisu z powrotem
onSaveRouteNadpisanie trasy po zwykłym zapisie

Composable zwraca następujące wartości:

KluczOpis
isSubmittingOdpowiednik mutation.isPending
handleSaveZapis z pozostaniem w formularzu
handleSaveAndReturnZapis wraz z powrotem na listę
handleCancelPowrót bez zapisu
mutationPrzekazana mutacja
createCustomSubmit(options)Tworzy własny handler zapisu

Przebieg zapisu ​

  1. Wyświetlenie globalnego spinnera Quasara.

  2. Wywołanie mutation.mutateAsync(values).

  3. Po powodzeniu — przekierowanie zależne od przycisku i trybu:

    PrzyciskTrybCel
    Zapisnowy rekord${alias}.edit z identyfikatorem z result.model_id
    Zapisnowy rekord, brak model_id${alias}.index
    Zapisedycjabez przekierowania — użytkownik zostaje na miejscu
    Zapis z powrotemdowolny${alias}.index
  4. W razie błędu — ctx.setErrors(getFormValidationErrors(error)).

  5. 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].name

Dzię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.

PropTypDomyślnieOpis
actionsArray[]Dodatkowe przyciski w postaci { name, label, icon, color, order, tooltip, callback, ...propsy q-btn }
mergeActionsBooleantrueDecyduje o scalaniu z przyciskami domyślnymi po polu name
bgColorString"white"Tło paska
hideCancelConfirmDialogBooleanfalsePomija potwierdzenie przy anulowaniu
hideSaveAndReturnBooleanfalseUkrywa 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.

KomponentOpakowujeDodatkowe propsy
VInputBaseInput → q-inputemituje change
VInputSlugVInputname domyślnie "slug", source — przycisk generuje slug z podanego tekstu
VSelectBaseSelect → q-select—
VDictionarySelectBaseDictionarySelecturl*, alias*, queryParams, immediate, searchable, searchField, paginate, perPage, optionValue, defaultOptions, v-model:searchPhrase
VDictionarySelectTableBaseDictionarySelectTablejak wyżej oraz optionLabel, columnLabel, sortable, disable; model tablicowy z dodawaniem wierszy
VSelectTreeBaseSelectTreenodes*, labelKey*, fieldAttrs, filterAttrs
VToggleBaseToggle—
VCheckboxBaseCheckbox—
VStatusTogglesBaseStatusTogglesnoTranslations; renderuje przełączniki active oraz translation_active
VDateTimePickerBaseDateTimePickerdatePicker, timePicker, defaultMask, defaultInputMask
VTimePickerBaseTimePickermask, format24h
VColorPickerBaseColorPicker—
VRatingPickerBaseRatingPicker—
VCommonStatusSelectBaseCommonStatusSelecttype: "common" dla wartości aktywny/nieaktywny albo "boolean" dla tak/nie
VTinyEditorBaseTinyEditorinitConfig, mergeConfig, path
VEditorSimpleBaseEditorSimpletoolbar, mergeWithDefaultToolbar, definitions
VContentBuilder—mode, opisany w Content builderze
VSeo—name, domyślnie "seo"
VFileCollectionMediaFileCollectionopisany w sekcji Media
VImageCollection—nameDesktop, nameMobile, stacked
VMediaUploadFieldMediaUploadFieldmediaName, uploadName, multiple, accept, size
VOptionSettingdynamicznyname*, 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żki

Przy 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" />
PropDomyślnieOpis
label—Etykieta pola
initConfig{}Konfiguracja TinyMCE
mergeConfigtrueWartość false oznacza użycie initConfig bez scalania z konfiguracją domyślną
path"/"Katalog startowy przy wstawianiu mediów
error, errorMessagenullStan 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.

Made by Moonbite