Skip to content

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): tag

Krok 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.js

Generator 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:

ElementWartość
Ścieżka API/blog/tags
Nazwy trasblog.tags.index, blog.tags.create, blog.tags.edit
Uprawnieniablog.tags.index, blog.tags.store, blog.tags.show
Klucze cachetagKeys 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łumaczone

Szerzej 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:

Trasameta.access
blog.tags.indexblog.tags.index
blog.tags.createblog.tags.store
blog.tags.editblog.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): newsletter

Po wygenerowaniu modułu:

  1. Dodaj klucz newsletter w src/i18n/en/menu-items.js oraz src/i18n/pl/menu-items.js. Bez niego menu wyświetli surowy klucz.
  2. Podmień ikonę description w src/menu-items.js — generator wstawia zawsze tę samą.
  3. Przenieś wpis z grupy modules do settings, jeżeli moduł należy do ustawień.
  4. Uruchom ponownie npm run make — dopiero wtedy nowy moduł pojawi się na liście generatora model.
  5. 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.
  6. Uruchom generator model dla pierwszego modelu — dopiero on tworzy katalogi components/, pages/, forms/ oraz api/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:

KomponentZastosowanie
BaseIconsTdKontener na ikony
BaseVisibilityBadgesOznaczenia widoczności rekordu
BasePublishedAtIconStatus publikacji na podstawie daty
BaseStatusChipChip 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 ​

ObjawPrawdopodobna przyczyna
Komponent nierozpoznawany w szablonieKatalog components/ modułu nie figuruje w dirs w quasar.config.js albo wymagany jest restart npm run dev
Komunikat useXyz is not definedKatalog composables/ modułu nie figuruje w dirs auto-importu
Breadcrumbs i tytuł prezentują surowy kluczBrak klucza <moduł>.<modele>.title.<akcja> w tłumaczeniach
Pozycja menu prezentuje surowy kluczBrak klucza w plikach src/i18n/{en,pl}/menu-items.js
Wejście na trasę kończy się przekierowaniem na 404Brak uprawnienia z meta.access w odpowiedzi GET auth/user
Formularz edycji pozostaje pustyBrak obserwatora watch na currentValues wraz z resetForm w komponencie formularza
Pole nie trafia do APIBrak 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 trasBrak wpisu w src/router/routes.js albo usunięty marker generatora
Trasa modułu prowadzi do strony 404Wpis znalazł się poniżej ...coreRoutes
Generator nie wstawił żadnej treściUsunięty komentarz // --place where generator will inject ...

Made by Moonbite