Motyw
Custom fields
Custom fields to stały zestaw pól przypisany do modelu — na przykład do strony typu home. Korzystają z tych samych typów treści co Content builder, lecz różni je autor struktury.
| Content builder | Custom fields | |
|---|---|---|
| Kto układa treść | redaktor w panelu | deweloper w kodzie |
| Co widzi redaktor | pusty builder i przycisk dodawania bloków | gotowy formularz o stałym kształcie |
| Skąd pochodzi lista typów | GET content-builder/section-types | schemat zadeklarowany w panelu |
| Adresowanie z frontu | pozycja bloku w drzewie | slug pola |
Slug pola stanowi kontrakt z frontem — pod tą nazwą treść pojawia się w API publicznym. Backend nie udostępnia endpointu ze schematem pól: definicja żyje wyłącznie w panelu, a backend zapisuje to, co dostanie.
Dostępne typy pól
js
// src/modules/contentBuilder/customFields/index.js
import {
defineCustomFields,
file,
header,
image,
link,
repeater,
text,
textImage,
youtube,
} from "modules/contentBuilder/customFields";| Typ | Builder | Kształt wartości | Opcje typu |
|---|---|---|---|
header | header() | { type, title, tag } | tags |
text | text() | { type, content } | editor |
text-image | textImage() | { type, content, media_files } | editor, collections, multiple |
image | image() | { type, media_files } | collections, multiple |
file | file() | { type, media_files } | collections, multiple |
youtube | youtube() | { type, link, width, height, autoplay, content, media_files } | editor, collections |
content-link | link() | { type, title, url, target } | — |
repeater | repeater(fields, options) | { type, items: [{ id, fields }] } | min, max, itemLabel |
Wartości domyślne opcji ustawia funkcja normalize deskryptora typu:
| Opcja | Domyślnie | Dotyczy |
|---|---|---|
tags | ["h1" … "h6", "span"] | header — przy jednym elemencie listy selektor znika |
editor | TEXT_EDITOR_TINY | text, text-image, youtube |
collections | DEFAULT_IMAGE_COLLECTIONS, czyli ["image_desktop", "image_mobile"] | image, text-image, youtube |
collections | DEFAULT_FILE_COLLECTIONS, czyli ["file"] | file |
multiple | false — w typie youtube wymuszone na false | pola z mediami |
min, max, itemLabel | 0, null, null | repeater |
Redaktory tekstu wskazuje jedna z trzech stałych: TEXT_EDITOR_TINY (TinyMCE), TEXT_EDITOR_SIMPLE (q-editor) oraz TEXT_EDITOR_PLAIN (zwykły v-input).
Typy shared-section i custom-item są niedostępne
Panel nie ma dla nich buildera ani deskryptora, i nie jest to przeoczenie. Backend przyjmuje typy z listy content-builder.custom-field-types, na której nie ma shared-section. Typ custom-item na tej liście jest, lecz wymaga pola custom_type o wartości z konfiguracji custom-sectionable-types — ta jest domyślnie pusta, więc każdy zapis takiego pola kończy się błędem 422.
Sekcja współdzielona nie może więc być typem custom fielda, ale sama może mieć custom fields — opisuje to następna sekcja.
Pola sekcji współdzielonych
Typy wybierane w formularzu sekcji są deklarowane w src/modules/contentBuilder/constants/shared-section-types.js. Schemat pól jest mapowany per typ przez sharedSectionCustomFields w src/modules/contentBuilder/constants/shared-section-custom-fields.js; formularz używa go dla wartości początkowych i walidacji. W bazowym repozytorium oba rejestry są puste — typy i pola dodaje projekt.
Przykład ilustracyjny: deklaracja pól dla typu cta (taki typ nie istnieje w bazowym repozytorium):
js
import { TEXT_EDITOR_PLAIN, defineCustomFields, defineCustomFieldsRegistry, header, link, text } from "modules/contentBuilder/customFields";
export const sharedSectionCustomFields = defineCustomFieldsRegistry({
cta: defineCustomFields(
{
title: header({ required: true, tags: ["h2"] }),
lead: text({ editor: TEXT_EDITOR_PLAIN }),
link: link(),
},
{ labelPrefix: "content-builder.shared-sections.custom_fields.cta" },
),
});Formularz zapisuje pola wraz z section_groups. W formularzu strony karta sekcji pozwala otworzyć i zapisać powiązaną sekcję współdzieloną bez kopiowania jej custom fields do strony. Odpowiadają za to ContentBuilderSharedSectionForm.vue i ContentBuilderSharedSectionCard.vue.
Backend obsługuje ten payload przez HasCustomFields i zasoby SharedSection; typy wartości muszą być dozwolone przez content-builder.custom-field-types. Zob. backendowy opis custom fields.
Deklaracja schematu
Schematy trzymane są w katalogu custom-fields/ modułu, po jednym pliku na typ modelu. Slug pola wynika z klucza obiektu:
js
// src/modules/page/custom-fields/home.js
export const homeCustomFields = defineCustomFields(
{
hero_title: header({ required: true }),
hero_lead: text({ editor: TEXT_EDITOR_SIMPLE }),
hero_image: image({ required: true, collections: ["hero_desktop", "hero_mobile"] }),
promo_video: youtube(),
about: textImage(),
brochure: file({ collections: ["brochure"] }),
pricing: file({ collections: ["pricing"] }),
benefits: repeater(
{
title: header({ required: true, tags: ["h3"], col: "col-md-6 col-12" }),
description: text({ editor: TEXT_EDITOR_PLAIN, col: "col-md-6 col-12" }),
icon: image({ collections: ["icon"], col: "col-12" }),
},
{ min: 1, max: 6, itemLabel: "title" },
),
},
{ labelPrefix: PAGE_CUSTOM_FIELDS_LABEL_PREFIX },
);Wywołanie defineCustomFields uzupełnia każdą definicję o slug, label, labelPrefix oraz wartości domyślne opcji. Zwrócony obiekt jest gotową definicją — komponenty i schematy walidacji korzystają z niego bez dalszej obróbki.
Opcje wspólne
| Opcja | Typ | Domyślnie | Opis |
|---|---|---|---|
required | Boolean | false | Włącza regułę wymagalności w schemacie pola |
label | String | klucz z labelPrefix i sluga | Etykieta pola albo klucz i18n |
col | String | "col-12" | Klasy siatki dla pól potomnych repeatera |
rules | funkcja | — | Otrzymuje gotowy schemat yup pola i zwraca własny |
Znaczenie opcji required zależy od typu: w header dotyczy tytułu, w text i text-image — treści, w youtube — odnośnika, a w image i file oznacza wymóg wskazania co najmniej jednego pliku w dowolnej z kolekcji. Media towarzyszące polom text-image oraz youtube pozostają opcjonalne niezależnie od tej wartości.
Repeater nie reaguje na required
Jego schemat opiera się wyłącznie na min i max. Repeater z obowiązkową pierwszą pozycją deklaruje się przez min: 1.
Opcja rules pozwala zawęzić regułę bez pisania własnego typu:
js
// src/modules/page/custom-fields/home.js
excerpt: text({
editor: TEXT_EDITOR_PLAIN,
rules: (schema) => schema.shape({ content: string().max(160).label("labels.description") }),
}),Opcja col dotyczy tylko pól wewnątrz repeatera
Automatycznie stosuje ją CustomFieldRepeaterItem.vue. Na najwyższym poziomie klasy siatki nadaje komponent układu, a dyspozytor bez takiego komponentu renderuje każde pole jako col-12. Wartość col można tam wykorzystać ręcznie — <custom-field :field="cf.brochure" :class="cf.brochure.col" />.
Podpięcie do formularza
Wartości początkowe oraz schemat walidacji budują dwie funkcje z modułu contentBuilder/services — obie są auto-importowane:
js
// src/modules/page/forms/page.form.js
export const pageForm = {
initialValues(currentValues) {
const customFields = getCustomFieldsInitialValues(
getPageCustomFields(currentValues?.type),
currentValues?.custom_fields,
);
const initialValues = {
// ...
custom_fields: customFields,
};
if (!currentValues) {
return initialValues;
}
return {
...getFormFields(currentValues, initialValues, ["custom_fields"]),
custom_fields: customFields,
};
},
validationSchema(type) {
return object({
// ...
custom_fields: getCustomFieldsValidationSchema(getPageCustomFields(type)),
});
},
};Klucz custom_fields wyłączony jest z getFormFields
Trzeci argument getFormFields pomija custom_fields przy przepisywaniu odpowiedzi API, bo dane z backendu muszą przejść przez getCustomFieldsInitialValues. Funkcja ta uzupełnia braki, odrzuca slugi spoza schematu i wypełnia kolekcje mediów pustymi tablicami. Kopia bez tego kroku daje formularz o niepełnym kształcie.
Schemat zależy od typu strony, więc w komponencie formularza jest wartością obliczaną:
js
// src/modules/page/components/PagePageForm.vue
const type = ref(props.currentValues?.type ?? "");
const { values, handleSubmit, resetForm, setFieldValue } = useForm({
initialValues: pageForm.initialValues(props.currentValues),
validationSchema: computed(() => pageForm.validationSchema(type.value)),
});
watch(
() => values.type,
(value) => {
type.value = value;
setFieldValue("custom_fields", getCustomFieldsInitialValues(getPageCustomFields(value)));
},
);Zmiana typu strony przestawia więc dwie rzeczy naraz: schemat walidacji i wartość pola custom_fields. Bez drugiego kroku w formularzu zostałyby wartości poprzedniego typu, niepasujące do nowego schematu.
Sekcję formularza pokazuje się warunkowo, na podstawie hasPageCustomFields:
vue
<!-- src/modules/page/components/PagePageForm.vue -->
<base-form-section
v-if="hasPageCustomFields(values.type)"
class="q-mb-lg"
:title="$t('page.pages.custom_fields.title')"
>
<page-page-custom-fields :type="values.type" />
</base-form-section>Schematy poszczególnych typów zbiera custom-fields/index.js:
js
// src/modules/page/custom-fields/index.js
const pageCustomFields = {
[PAGE_TYPE_HOME]: homeCustomFields,
[PAGE_TYPE_BLOG]: blogCustomFields,
// ...
};
export function getPageCustomFields(type) {
return pageCustomFields[type] ?? {};
}
export function hasPageCustomFields(type) {
return Object.keys(getPageCustomFields(type)).length > 0;
}Typy bez własnych pól mają puste schematy — defineCustomFields({}, { labelPrefix }). Taki plik jest gotowym miejscem na przyszłe pola i sprawia, że getPageCustomFields nigdy nie zwraca wartości nieokreślonej.
Układ pól
Za wybór komponentu pola odpowiada dyspozytor CustomField.vue, który składa ścieżkę z propa name i sluga:
vue
<!-- src/modules/contentBuilder/components/customFields/CustomField.vue -->
<component :is="componentIs" :field="field" :name="`${name}.${field.slug}`" />Prop name domyślnie wskazuje na custom_fields, więc wystarczy przekazać definicję. Komponent układu jest opcjonalny — bez niego schemat renderowany jest po kolei:
vue
<!-- src/modules/page/components/customFields/PagePageCustomFields.vue -->
<component :is="componentIs" v-if="componentIs" />
<div v-else class="row q-col-gutter-md">
<custom-field v-for="field in fields" :key="field.slug" :field="field" class="col-12" />
</div>Komponent układu daje kontrolę nad kolejnością, szerokościami i nagłówkami sekcji:
vue
<!-- src/modules/page/components/customFields/PagePageHomeCustomFields.vue -->
<script setup>
import { homeCustomFields as cf } from "modules/page/custom-fields/home";
</script>
<template>
<div class="row q-col-gutter-md">
<custom-fields-section class="col-12" :title="$t('page.pages.custom_fields.sections.hero')" />
<custom-field :field="cf.hero_title" class="col-12" />
<custom-field :field="cf.hero_lead" class="col-12" />
<custom-field :field="cf.hero_image" class="col-12" />
<custom-field :field="cf.brochure" class="col-md-6 col-12" />
<custom-field :field="cf.pricing" class="col-md-6 col-12" />
<custom-field :field="cf.benefits" class="col-12 q-mt-lg" />
</div>
</template>Nowy układ dopisuje się do mapy w PagePageCustomFields.vue:
js
// src/modules/page/components/customFields/PagePageCustomFields.vue
const layouts = {
[PAGE_TYPE_HOME]: defineAsyncComponent(
() => import("modules/page/components/customFields/PagePageHomeCustomFields.vue"),
),
};Dyrektywa v-if na polu kasuje treść przy zapisie
Odmontowane pole traci swoją wartość: useField w onBeforeUnmount woła form.unsetPathValue(path), ponieważ keepValuesOnUnmount ma wartość false, a unsetPath przycina w górę opróżnione kontenery. Slug znika wtedy z ładunku żądania, co dla backendu oznacza polecenie usunięcia pola wraz z treścią — we wszystkich językach.
Do warunkowego ukrywania pól służy v-show, a przy większych różnicach — osobny typ strony z własnym schematem.
Repeater
Repeater renderuje listę pozycji na useFieldArray i vuedraggable, w układzie znanym z content buildera. Nagłówek pozycji pokazuje podgląd pola wskazanego przez itemLabel, a przy braku wartości — etykietę content-builder.custom-fields.labels.item. Podgląd wylicza funkcja preview deskryptora typu, dostępna w typach header, text, text-image i youtube.
Układ pól potomnych wynika z opcji col w schemacie. Gdy to nie wystarcza, #item udostępnia ścieżkę pozycji:
vue
<custom-field :field="cf.benefits" class="col-12">
<template #item="{ path, index }">
<div class="row q-col-gutter-md">
<custom-field :field="cf.benefits.fields.title" :name="path" class="col-8" />
<custom-field :field="cf.benefits.fields.icon" :name="path" class="col-4" />
</div>
</template>
</custom-field>Slot przekazywany jest przez CustomField.vue oraz CustomFieldRepeater.vue aż do pozycji, więc deklaruje się go na <custom-field>. Zmienna path zawiera pełną ścieżkę pól pozycji, w rodzaju custom_fields.benefits.items[0].fields.
Repeater w repeaterze nie zadziała
Struktura backendu przewiduje dokładnie jeden poziom pozycji. Zagnieżdżenie zgłasza ostrzeżenie w konsoli deweloperskiej i nie zapisze się poprawnie.
Etykiety
Domyślne klucze buduje defineCustomFields z opcji labelPrefix i sluga:
| Element | Klucz | Przykład |
|---|---|---|
| Pole | <labelPrefix>.<slug> | page.pages.custom_fields.hero_title |
| Repeater | <labelPrefix>.<slug>.label | page.pages.custom_fields.benefits.label |
| Pole potomne repeatera | <labelPrefix>.<slug>.<childSlug> | page.pages.custom_fields.benefits.title |
js
// src/modules/page/custom-fields/labels.js
export const PAGE_CUSTOM_FIELDS_LABEL_PREFIX = "page.pages.custom_fields";js
// src/modules/page/trans/pl/pages.js
custom_fields: {
title: "Treść strony",
sections: { hero: "Sekcja hero", media: "Materiały" },
collections: { hero_desktop: "Zdjęcie", hero_mobile: "Zdjęcie mobile" },
hero_title: "Nagłówek hero",
benefits: {
label: "Korzyści",
collections: { icon: "Ikona" },
title: "Tytuł",
},
},Etykieta repeatera trafia do klucza label, ponieważ ta sama gałąź mieści etykiety pól potomnych oraz jego kolekcje mediów.
Etykiety rozwiązuje useCustomFieldLabel — gdy klucz nie istnieje w tłumaczeniach, zwraca podaną wartość dosłownie. Opcja label: "Nagłówek hero" zadziała więc równie dobrze jak klucz i18n, tyle że bez tłumaczenia.
Nazwy kolekcji mediów rozwiązuje useMediaCollectionLabel, kaskadą trzech kroków:
<labelPrefix>.collections.<nazwa>— etykieta z modułu,media.files.labels.<nazwa>— słownik wspólny opisany w sekcji Media,- surowa nazwa kolekcji.
Nazwy kolekcji muszą być niepowtarzalne w obrębie poziomu schematu
Kaskada korzysta z labelPrefix pola, wspólnego dla wszystkich pól tego samego poziomu. Dwa pola plikowe z domyślną kolekcją file dostaną tę samą etykietę przycisku — stąd collections: ["brochure"] i collections: ["pricing"] w przykładowym schemacie.
Kontrakt z backendem
Klucz custom_fields to pełny stan, nie zmiana przyrostowa
Backend porównuje przysłane slugi z zapisanymi i kasuje wszystko, czego w ładunku nie ma — razem z treścią, bo usunięcie rekordu CustomField pociąga za sobą usunięcie powiązanego modelu treści.
| Ładunek | Skutek |
|---|---|
brak klucza custom_fields | pola pozostają nietknięte |
custom_fields: {} | kasowane są wszystkie pola modelu |
| slug nieobecny w ładunku | pole kasowane wraz z treścią |
Dlatego panel przy każdym zapisie wysyła komplet pól ze schematu, także puste.
Struktura — lista slugów oraz liczba i kolejność pozycji repeatera — jest wspólna dla wszystkich języków treści. Tłumaczone są wartości: treści tekstowe i przypisania plików. Pominięcie pustego pola podczas edycji wersji angielskiej skasowałoby zatem także treść polską.
| Element ładunku | Znaczenie po stronie backendu |
|---|---|
type | Alias typu z mapy types-morph-map; wskazuje model treści |
items[].id | Obecny — aktualizacja pozycji, brak — wstawienie nowej |
kolejność items[] | Zapisywana jako kolumna order |
itemable_id | Ignorowany dla obsługiwanych typów; panel go nie odsyła |
media_files | Grupowane po nazwie kolekcji, synchronizacja idzie per kolekcja |
Odpowiedź API zwraca media_files jako null, gdy do pola nie podpięto żadnego pliku — getCustomFieldsInitialValues zamienia to na komplet pustych tablic dla zadeklarowanych kolekcji.
Zmiana listy collections osierocą dotychczasowe pliki
Backend synchronizuje wyłącznie te kolekcje, które znalazł w ładunku. Kolekcja usunięta ze schematu przestaje być wysyłana, więc jej pliki zostają przypisane do pola, a przestają być widoczne w panelu.
Pola wewnątrz repeatera waliduje wyłącznie panel
Reguły backendu sprawdzają dla repeatera tylko to, czy items jest tablicą. Błędy pozostałych pól wracają pod kluczami custom_fields.<slug>.<atrybut>, bez wskazania pozycji, więc nie da się ich przypisać do konkretnego pola formularza. Wymagalność pól potomnych musi zadziałać po stronie panelu, zanim żądanie wyruszy.
Walidacja
Reguły custom fields wchodzą do schematu całego formularza, a nie do pojedynczych pól. Wymusza to sposób działania vee-validate: gdy useForm otrzymał validationSchema, reguły przekazane do useField są pomijane.
js
// node_modules/vee-validate/dist/vee-validate.mjs — _useField
const validator = computed(() => {
const schema = toValue(form?.schema);
if (schema) {
return undefined;
}
// ...
});Odpowiada to zasadzie z dokumentacji biblioteki: „You can only validate using field-level validation or form-level validation, you cannot mix the two approaches". Stąd getCustomFieldsValidationSchema buduje jeden schemat obejmujący wszystkie pola, a validationSchema może być wartością obliczaną — zmiana referencji uruchamia ponowną walidację w trybie validated-only.
Moment wyświetlenia błędu
Zwykłe pola formularza pokazują błąd po dotknięciu (meta.touched), co opisują Formularze. Pola bez zarejestrowanego stanu — tablica items repeatera i obiekt media_files — zachowują się inaczej:
js
// node_modules/vee-validate/dist/vee-validate.mjs — validateSchema
// field not rendered
if (!pathState) {
setFieldError(path, messages);
return validation;
}
if (mode === 'silent') {
return validation;
}Gałąź dla ścieżek bez zarejestrowanego pola wykonuje się przed sprawdzeniem trybu, więc błąd ustawiany jest już podczas cichej walidacji przy montowaniu formularza. Pusty formularz świeciłby wtedy na czerwono, zanim redaktor cokolwiek zrobi. Dlatego takie pola korzystają z useCustomFieldError, który przepuszcza komunikat dopiero po pierwszej próbie zapisu:
js
// src/modules/contentBuilder/composables/custom-fields.js
export function useCustomFieldError(path) {
const submitCount = useSubmitCount();
const errorMessage = useErrorMessage(useFieldError(path));
return computed(() => (submitCount.value > 0 ? errorMessage.value : ""));
}Zapis wysyła wynik parsowania yup, a nie surowe wartości formularza
W makeSubmissionFactory gałąź result.source === 'schema' podstawia result.values. Klucze spoza schematu przechodzą — nie działa stripUnknown, dzięki czemu w ładunku zostaje type każdego pola, choć nie ma go w regułach. Schemat musi jednak wiernie odwzorowywać kształt wartości: to jego wynik trafia do żądania.
Hierarchia wizualna
Klasy zdefiniowane są w src/css/_custom-fields.scss:
| Klasa | Rola | Komponent |
|---|---|---|
cf-section | Nagłówek sekcji — wersalikowa etykieta z linią | CustomFieldsSection.vue |
cf-block | Lewa belka z nazwą pola złożonego | CustomFieldGroup.vue |
cf-empty | Kreskowana ramka pustego repeatera | CustomFieldRepeater.vue |
cf-error | Komunikat błędu pola bez własnej kontrolki | pola z mediami, repeater |
base-field-label | Etykieta podpola | pola z mediami, edytor prosty |
Belki cf-block nawiązują do poziomów cb-level-1/2/3 z content buildera, dzięki czemu oba mechanizmy czyta się na jednym ekranie tak samo.
Dwa zagnieżdżone div-y w CustomFieldGroup są celowe
vue
<!-- src/modules/contentBuilder/components/customFields/CustomFieldGroup.vue -->
<div>
<div class="cf-block">
<div v-if="title" class="cf-block__title">{{ title }}</div>
<slot />
</div>
</div>Element zewnętrzny przyjmuje klasy siatki, wewnętrzny dokłada wcięcie. Po scaleniu ich w jeden element padding-left: 16px z .cf-block trafiłby na padding-left: 16px z reguły .q-col-gutter-md > * — ta sama specyficzność i ta sama wartość — i belka przestałaby robić wcięcie.
Zasady układu
Wskazówki wypracowane na gotowych ekranach:
- Pola złożone —
youtube,text-imageorazrepeater— zawsze na pełnej szerokości. - W kolumny dziel wyłącznie pola o zbliżonej wysokości: dwa zwykłe inputy albo dwa pola plikowe. Input postawiony obok edytora WYSIWYG zostawia pod sobą dziurę.
- Nie mieszaj w jednym rzędzie kontrolek z etykietą pływającą (
v-input) i z etykietą umieszczoną nad kontrolką (uploady) — krawędzie się rozjeżdżają. - Pole złożone samo się nazywa, więc nagłówek sekcji nad pojedynczym takim polem powtarza jedynie tę samą nazwę.
Ostrzeżenia w trybie deweloperskim
Schemat sprawdzany jest przy jego budowaniu i przy przygotowywaniu wartości początkowych. Ostrzeżenia trafiają do konsoli wyłącznie w trybie deweloperskim, poprzedzone przedrostkiem [custom-fields].
| Komunikat | Przyczyna |
|---|---|
unknown field type | Typ spoza rejestru customFieldTypes |
use a non-numeric text slug | Slug niepasujący do /^[a-z_][a-z0-9_-]*$/i — PHP zamienia klucze liczbowe na liczby całkowite |
nested repeaters are not supported | Repeater wewnątrz repeatera |
repeater has no fields | Repeater z pustym obiektem pól |
itemLabel … does not match any repeater field | Wartość itemLabel bez odpowiednika wśród pól potomnych |
the API returned slugs missing from the schema | Backend zwrócił pole, którego nie ma w schemacie — nie da się go edytować i zostanie skasowane przy zapisie |
Ostatni komunikat pojawia się zwykle po zmianie nazwy sluga w schemacie. Treść zapisana pod poprzednią nazwą pozostaje w bazie do najbliższego zapisu formularza.
Dodanie nowego typu pola
Krok 1: utwórz deskryptor typu
js
// src/modules/contentBuilder/customFields/types/quote.js
import { object, string } from "yup";
import { QUOTE } from "modules/contentBuilder/constants/section-types";
export default {
type: QUOTE,
component: defineAsyncComponent(
() => import("modules/contentBuilder/components/customFields/CustomFieldQuote.vue"),
),
normalize: (field) => ({
maxLength: field.maxLength ?? 240,
}),
toValues: (field, value) => ({
type: QUOTE,
quote: value?.quote ?? "",
author: value?.author ?? "",
}),
schema: (field) =>
object({
quote: (field.required ? string().required() : string().nullable())
.max(field.maxLength)
.label(field.label),
author: string().nullable().label("labels.author"),
}),
preview: (value) => value?.quote ?? "",
};| Klucz | Rola |
|---|---|
type | Alias typu, wspólny z backendem |
component | Komponent pola, ładowany asynchronicznie |
normalize | Wartości domyślne opcji typu, dokładane przez defineCustomFields |
toValues | Wartości początkowe na podstawie odpowiedzi API; klucz type jest obowiązkowy |
schema | Schemat yup pola; drugi argument daje dostęp do toValues i schema dla pól potomnych |
preview | Opcjonalny podgląd tekstowy dla nagłówka pozycji repeatera |
Krok 2: dopisz typ do barrelu
js
// src/modules/contentBuilder/customFields/types/index.js
import quote from "modules/contentBuilder/customFields/types/quote";
export const customFieldTypes = [header, text, textImage, image, file, youtube, repeater, quote]
.reduce(/* ... */);Krok 3: wystaw builder
js
// src/modules/contentBuilder/customFields/define.js
export const quote = createField(QUOTE);js
// src/modules/contentBuilder/customFields/index.js
export { defineCustomFields, file, header, image, quote, repeater, text, textImage, youtube }
from "modules/contentBuilder/customFields/define";Katalog customFields nie jest auto-importowany
Buildery i defineCustomFields importuje się jawnie z modules/contentBuilder/customFields. Auto-import obejmuje contentBuilder/composables oraz contentBuilder/services, czyli useCustomFieldLabel, useCustomFieldError, getCustomFieldsInitialValues i getCustomFieldsValidationSchema.
Krok 4: utwórz komponent pola
vue
<!-- src/modules/contentBuilder/components/customFields/CustomFieldQuote.vue -->
<script setup>
const props = defineProps({
field: { type: Object, required: true },
name: { type: String, required: true },
});
const label = useCustomFieldLabel(() => props.field);
</script>
<template>
<custom-field-group :title="label">
<div class="row q-col-gutter-md">
<v-input class="col-12" :name="`${name}.quote`" bg-color="white" :label="label" />
<v-input class="col-12" :name="`${name}.author`" bg-color="white" :label="$t('labels.author')" />
</div>
</custom-field-group>
</template>Komponent otrzymuje pełną ścieżkę pola w propie name, więc nazwy kontrolek buduje się przez interpolację. Nazwa zapisana na sztywno nie podepnie się pod formularz i przestanie działać po przestawieniu pozycji repeatera.
Krok 5: zgłoś typ po stronie backendu
Panel nie wystarcza — typ musi znać także backend
Nowy alias trzeba dopisać do mapy types-morph-map oraz do listy custom-field-types w pliku Modules/ContentBuilder/Config/config.php, a model treści musi rozszerzać klasę SectionType. Bez tego zapis kończy się błędem 422 na regule Rule::in. Opisują to Niestandardowe pola w części backendowej.