Motyw
Krok po kroku
Strona zawiera praktyczne ścieżki prowadzące od pustego katalogu do działającego ekranu. Przykładem przewodnim jest model tag dodawany do istniejącego modułu blog.
Nowy model CRUD od podstaw
Krok 1: uruchom generator
bash
npm run make? Select generator: model
? Select module: blog
? Model name (singular, snake_case, e.g. blog_post): tagKrok 2: sprawdź wygenerowane pliki
src/modules/blog/api/endpoints/tag.js
src/modules/blog/api/queries/tag.queries.js
src/modules/blog/api/mutations/tag.mutations.js
src/modules/blog/forms/tag.form.js
src/modules/blog/components/BlogTagForm.vue
src/modules/blog/components/BlogTagsTable.vue
src/modules/blog/pages/tags/TagIndexPage.vue
src/modules/blog/pages/tags/TagCreatePage.vue
src/modules/blog/pages/tags/TagEditPage.vue
src/modules/blog/routes/tags.js
src/modules/blog/trans/en/tags.js
src/modules/blog/trans/pl/tags.jsGenerator modyfikuje ponadto trzy istniejące pliki: blog/api/keys.js, blog/routes/index.js oraz blog/trans/{en,pl}/index.js.
Przyjmuje przy tym następujące ustawienia:
| Element | Wartość |
|---|---|
| Ścieżka API | /blog/tags |
| Nazwy tras | blog.tags.index, blog.tags.create, blog.tags.edit |
| Uprawnienia | blog.tags.index, blog.tags.store, blog.tags.show |
| Klucze cache | tagKeys w pliku blog/api/keys.js |
Uruchom aplikację już na tym etapie
Po npm run dev lista powinna otworzyć się pod adresem /blog/tags — o ile backend zwraca dane, a użytkownik ma uprawnienia. Pola uzupełniaj dopiero po tej weryfikacji: inaczej nie wiadomo, czy późniejszy błąd pochodzi z formularza, czy z generatora.
Krok 3: uzupełnij definicję formularza
Generator zostawia w pliku komentarze // TODO: add fields oraz // TODO: add validation — zastąp je polami modelu:
js
// src/modules/blog/forms/tag.form.js
import { bool, object, string } from "yup";
import { getFormFields } from "modules/core/utils/form";
export const tagForm = {
initialValues(currentValues) {
const initialValues = {
name: "",
slug: "",
active: false,
};
if (currentValues) {
return getFormFields(currentValues, initialValues);
}
return initialValues;
},
validationSchema() {
return object({
name: string().ensure().required().max(255).label("labels.name"),
slug: string().ensure().nullable().max(255).label("labels.slug"),
active: bool().label("labels.active"),
});
},
};Metoda label() przyjmuje klucz i18n, a nie tekst
js
string().required().label("labels.name") // ✔ klucz tłumaczenia
string().required().label("Nazwa") // ✘ zadziała, lecz nie zostanie przetłumaczoneSzerzej opisują to Formularze.
Obiekt initialValues określa zakres wysyłanych danych
Funkcja getFormFields przepuszcza z odpowiedzi API wyłącznie klucze obecne w initialValues. Pole pominięte w tym obiekcie nie zostanie wysłane do backendu — to najczęstsza przyczyna „zapis nie zapisuje jednego pola".
Krok 4: dodaj pola do formularza
vue
<!-- src/modules/blog/components/BlogTagForm.vue -->
<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="name" :label="$t('labels.name')" />
<v-input-slug class="col-lg-4 col-sm-6 col-12" :source="values.name" />
</div>
</base-form-section>
<base-form-btn-group
:hide-save-and-return="!currentValues"
@submit="handleSave"
@submit-and-return="handleSaveAndReturn"
@cancel="handleCancel"
/>
</base-form>
</template>Wartość values wymaga dopisania do destrukturyzacji
Szablon generatora pobiera z useForm wyłącznie { handleSubmit, resetForm }. Korzystanie z values, na przykład w propie :source komponentu v-input-slug, wymaga rozszerzenia zapisu:
js
const { values, handleSubmit, resetForm } = useForm({ ... });Krok 5: dostosuj kolumny i filtry tabeli
Generator tworzy kolumny serial_no, name, created_at oraz actions. Dopasuj je do modelu:
js
// src/modules/blog/components/BlogTagsTable.vue
const columns = computed(() => [
{ name: "serial_no", required: true, label: t("table.columns.lp"), align: "left" },
{
name: "name",
required: true,
label: t("table.columns.name"),
field: "name",
align: "left",
sortable: true,
},
{
name: "slug",
required: true,
label: t("table.columns.slug"),
field: "slug",
align: "left",
sortable: true,
},
{
name: "created_at",
required: true,
label: t("table.columns.created_at"),
field: "created_at",
format: formatDate,
align: "left",
sortable: true,
},
{
name: "actions",
required: true,
label: t("table.columns.actions"),
align: "left",
style: "width: 100px;",
},
]);Filtry oraz akcje wierszy opisują Tabele.
Krok 6: uzupełnij tłumaczenia
js
// src/modules/blog/trans/pl/tags.js
export default {
title: {
index: "Tagi",
edit: "Edycja tagu",
create: "Tworzenie tagu",
},
labels: {},
placeholders: {},
columns: {},
buttons: {
create_tag: "Dodaj tag",
},
};Uzupełnij analogiczny plik w katalogu trans/en/.
Klucze title.* są wymagane
Composable useCoreBreadcrumbs oraz tytuły stron odczytują blog.tags.title.<akcja>. Ich brak skutkuje wyświetleniem surowych kluczy zamiast tekstu. Po dodaniu trasy spoza schematu — na przykład reorder — uzupełnij również klucz title.reorder.
Etykiety pól, takie jak labels.name czy labels.slug, mają zasięg globalny. Zanim dodasz własną, sprawdź, czy nie istnieje już w modules/core/trans/{en,pl}.
Krok 7: dodaj pozycję w menu
js
// src/menu-items.js — wewnątrz grupy modules, w children modułu blog
{
name: t("blog.tags.title.index"),
to: { name: "blog.tags.index" },
access: "blog.tags.index",
},Wybór między podpozycją a pozycją najwyższego poziomu
Moduł blog ma już podpozycje dla artykułów i kategorii, więc tag trafia do tej samej grupy. Pozycja najwyższego poziomu — typowa dla nowego modułu — wymaga dodatkowo klucza w plikach src/i18n/{en,pl}/menu-items.js.
Krok 8: sprawdź uprawnienia po stronie backendu
Trasy generowane są wraz z polem meta.access:
| Trasa | meta.access |
|---|---|
blog.tags.index | blog.tags.index |
blog.tags.create | blog.tags.store |
blog.tags.edit | blog.tags.show |
Klucze te muszą występować w uprawnieniach zwracanych przez GET auth/user — w przeciwnym razie guard zablokuje wejście na ekran. Opisuje to Autentykacja.
Krok 9: przejdź listę kontrolną
Nowy moduł
bash
npm run make? Select generator: module
? Module name (snake_case, e.g. blog): newsletterPo wygenerowaniu modułu:
- Dodaj klucz
newsletterwsrc/i18n/en/menu-items.jsorazsrc/i18n/pl/menu-items.js. Bez niego menu wyświetli surowy klucz. - Podmień ikonę
descriptionwsrc/menu-items.js— generator wstawia zawsze tę samą. - Przenieś wpis z grupy
modulesdosettings, jeżeli moduł należy do ustawień. - Uruchom ponownie
npm run make— dopiero wtedy nowy moduł pojawi się na liście generatoramodel. - Zrestartuj
npm run dev. Generator zmieniłquasar.config.js, dopisując katalogi auto-importu i komponentów, a Vite nie przeładowuje konfiguracji w locie. - Uruchom generator
modeldla pierwszego modelu — dopiero on tworzy katalogicomponents/,pages/,forms/orazapi/endpoints/.
Szczegóły opisują Moduły i generatory.
Kolumna z własną zawartością
vue
<!-- src/modules/blog/components/BlogTagsTable.vue -->
<base-table :columns="columns" ...>
<template #body-cell-status="props">
<q-td :props="props">
<base-status-chip :active="props.row.active" />
</q-td>
</template>
</base-table>js
{ name: "status", required: true, label: t("table.columns.status"), align: "left" }Kolumna renderowana wyłącznie slotem
Taka kolumna nie wymaga pola field. Nie powinna też otrzymywać sortable, chyba że backend rozpoznaje jej nazwę.
Gotowe komponenty do wykorzystania w takiej kolumnie:
| Komponent | Zastosowanie |
|---|---|
BaseIconsTd | Kontener na ikony |
BaseVisibilityBadges | Oznaczenia widoczności rekordu |
BasePublishedAtIcon | Status publikacji na podstawie daty |
BaseStatusChip | Chip aktywny/nieaktywny |
Akcja wiersza
js
// src/modules/blog/components/BlogTagsTable.vue
const { actions, performAction, ... } = useCoreTable({
resource: "/blog/tags",
alias: "blog.tags",
actions: {
duplicate: {
order: 1.5,
icon: { name: "feather_copy", color: "neutral-500" },
tooltip: t("blog.tags.tooltips.duplicate"),
callback: (row) => duplicate(row.id),
visibility: (row) => row.active,
},
},
});Aby usunąć akcję domyślną, przekaż false:
js
actions: { delete: false }Pełną listę pól akcji zawierają Tabele.
Akcje masowe
vue
<!-- src/modules/blog/components/BlogTagsTable.vue -->
<script setup>
const selected = ref([]);
</script>
<template>
<base-table v-model:selected="selected" selection="multiple" row-key="id" ...>
<template #top-after-filters>
<div v-if="selected.length" class="row q-gutter-sm q-mt-sm">
<base-btn color="negative" :label="$t('buttons.delete')" @click="destroySelected" />
</div>
</template>
</base-table>
</template>Wzorcem referencyjnym jest plik src/modules/media/components/MediaFilesTable.vue.
Pole media w formularzu
js
// src/modules/blog/forms/tag.form.js
initialValues() {
return {
// ...
media_files: getImageCollectionInitialValues("cover"),
};
}vue
<!-- src/modules/blog/components/BlogTagForm.vue -->
<v-image-collection
:label="$t('labels.cover')"
name-desktop="media_files.cover_desktop"
name-mobile="media_files.cover_mobile"
class="col-12"
/>Nazwy pól muszą odpowiadać przyjętemu prefiksowi
Domyślne wartości nameDesktop i nameMobile zakładają prefiks image. Przy innym prefiksie trzeba podać je jawnie, jak w powyższym przykładzie, co opisuje sekcja Media.
Content builder w formularzu
js
// src/modules/blog/forms/tag.form.js
initialValues() {
return { /* ... */ section_groups: [] };
},
validationSchema() {
return object({
// ...
section_groups: array().of(getContentBuilderValidationSchema()),
});
}js
// src/modules/blog/api/queries/tag.queries.js
queryFn: async () => {
const { data } = await getTag(toValue(id));
return {
...data.model,
section_groups: transformContentBuilderData(data.model.section_groups),
};
},vue
<!-- src/modules/blog/components/BlogTagForm.vue -->
<base-form-section class="q-mb-lg">
<v-content-builder />
</base-form-section>Wywołania transformContentBuilderData nie wolno pominąć
Bez niego content builder nie wczyta istniejącej treści podczas edycji — formularz otworzy się pusty, bez żadnego błędu. Opisuje to Content builder.
Trasa spoza schematu CRUD
js
// src/modules/blog/routes/tags.js — wewnątrz children
{
path: "reorder",
name: `${tagRouteName}.reorder`,
meta: { access: `${tagRouteName}.update` },
component: () => import("modules/blog/pages/tags/TagReorderPage.vue"),
},Kolejność dzieci ma znaczenie
Trasy statyczne, takie jak reorder czy create, muszą znaleźć się przed :id, w przeciwnym razie trasa parametryczna je przechwyci.
Dodaj również klucz blog.tags.title.reorder w tłumaczeniach — bez niego breadcrumbs wyświetlą surowy klucz.
Endpoint spoza CRUD
js
// src/modules/blog/api/endpoints/tag.js
export const mergeTags = (data) => api.post(`${url}/merge`, data);js
// src/modules/blog/api/mutations/tag.mutations.js
export function useTagMergeMutation(options = {}) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (values) => mergeTags(values),
onSuccess: () => queryClient.invalidateQueries({ queryKey: tagKeys.table() }),
...options,
});
}Toastów nie obsługuje się samodzielnie
Toasty sukcesu i błędów wystawia interceptor axiosa. Opisuje to Warstwa API.
Rozwiązywanie problemów
| Objaw | Prawdopodobna przyczyna |
|---|---|
| Komponent nierozpoznawany w szablonie | Katalog components/ modułu nie figuruje w dirs w quasar.config.js albo wymagany jest restart npm run dev |
Komunikat useXyz is not defined | Katalog composables/ modułu nie figuruje w dirs auto-importu |
| Breadcrumbs i tytuł prezentują surowy klucz | Brak klucza <moduł>.<modele>.title.<akcja> w tłumaczeniach |
| Pozycja menu prezentuje surowy klucz | Brak klucza w plikach src/i18n/{en,pl}/menu-items.js |
| Wejście na trasę kończy się przekierowaniem na 404 | Brak uprawnienia z meta.access w odpowiedzi GET auth/user |
| Formularz edycji pozostaje pusty | Brak obserwatora watch na currentValues wraz z resetForm w komponencie formularza |
| Pole nie trafia do API | Brak klucza w initialValues, przez co getFormFields go odcina |
| Błąd 422 wyświetla toast, lecz nie podświetla pola | Ścieżka pola nie odpowiada kluczowi błędu zwróconemu przez backend |
| Nowy moduł nie posiada tras | Brak wpisu w src/router/routes.js albo usunięty marker generatora |
| Trasa modułu prowadzi do strony 404 | Wpis znalazł się poniżej ...coreRoutes |
| Generator nie wstawił żadnej treści | Usunięty komentarz // --place where generator will inject ... |