Motyw
Tabele
Każda lista w panelu składa się z dwóch elementów: composable useCoreTable odpowiada za dane, paginację, filtry i akcje, a komponent BaseTable stanowi warstwę wizualną nad QTable. Paginacja, sortowanie i filtrowanie odbywają się po stronie serwera.
Kompletny przykład
vue
<!-- src/modules/blog/components/BlogArticlesTable.vue -->
<script setup>
const { t } = useLocale();
const { rows, loading, pagination, filters, hasActiveFilters, onRequest, actions, performAction } =
useCoreTable({
resource: "/blog/articles",
alias: "blog.articles",
filterSchema: {
search: { defaultValue: null },
},
immediate: true,
});
const columns = computed(() => [
{ name: "serial_no", required: true, label: t("table.columns.lp"), align: "left" },
{
name: "title",
required: true,
label: t("table.columns.title"),
field: "title",
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: "icons", required: true, align: "left" },
{
name: "actions",
required: true,
label: t("table.columns.actions"),
align: "left",
style: "width: 100px;",
},
]);
</script>
<template>
<base-table
v-model:pagination="pagination"
v-model:filter="filters.search"
class="core-table"
:rows="rows"
:columns="columns"
:loading="loading"
:actions="actions"
:has-active-filters
row-key="id"
@request="onRequest"
@perform-action="performAction"
>
<template #base-page-top-right>
<div class="full-width">
<base-btn
color="primary"
text-color="onyx"
:label="$t('buttons.create')"
:to="{ name: 'blog.articles.create' }"
icon="add"
/>
</div>
</template>
<template #body-cell-icons="props">
<base-icons-td :props="props">
<base-visibility-badges
:active="props.row.active"
:translation-active="props.row.translation_active"
:locale="props.row.locale"
/>
<base-published-at-icon :published-at="props.row.published_at" />
</base-icons-td>
</template>
<template v-for="(_, slot) of $slots" #[slot]="scope">
<slot :name="slot" v-bind="scope || {}" />
</template>
</base-table>
</template>Strona indeksu ogranicza się do jednego tagu:
vue
<!-- src/modules/blog/pages/articles/ArticleIndexPage.vue -->
<base-card-page :title="$t('blog.articles.title.index')" :breadcrumbs="breadcrumbs">
<blog-articles-table />
</base-card-page>Composable useCoreTable
Opcje
| Opcja | Domyślnie | Opis |
|---|---|---|
resource | — | Ścieżka API, na przykład "/blog/articles"; może być typu ref |
alias | null | Prefiks nazwy trasy, na przykład "blog.articles", na podstawie którego akcja edycji celuje w blog.articles.edit |
filterSchema | {} | Definicja filtrów, opisana poniżej |
defaultPagination | { rowsPerPage: 10, sortBy: "id", descending: true } | Stan początkowy |
persistence | "query" | Wartość "query" oznacza URL, "storage" — sessionStorage, dowolna inna — pamięć |
storageKey | null | Wymagany przy persistence: "storage" |
immediate | true | Określa, czy dane pobierane są od razu |
actions | {} | Nadpisania rejestru akcji wiersza, opisane poniżej |
additionalParams | {} | Dodatkowe parametry żądania, mogą być reaktywne |
deleteConfirmMessage | null | Własna treść komunikatu w dialogu usuwania |
Zwracane wartości
| Klucz | Opis |
|---|---|
rows | Zawartość data.results albo pusta tablica |
rawData | Pełna odpowiedź, przydatna przy dodatkowych danych z backendu |
loading | Suma logiczna isPending, isFetching oraz isDeleting |
pagination | Obiekt paginacji przeznaczony dla v-model:pagination |
filters | Reaktywny obiekt wartości filtrów |
hasActiveFilters | Informacja, czy ustawiony został jakikolwiek filtr |
resetFilters | Przywraca wartości domyślne |
onRequest | Handler zdarzenia @request |
fetchRows | Ręczne odświeżenie danych, czyli refetch |
actions | Posortowana lista akcji przeznaczona dla propa :actions |
performAction | Handler zdarzenia @perform-action |
Kontrakt z serwerem
Wysyłane parametry:
page, per_page, order_by, order_direction (asc|desc), + parametry filtrówOczekiwana odpowiedź:
json
{ "results": [ ... ], "pagination": { "total": 128 } }Wartość pagination.rowsNumber ustawiana jest automatycznie na podstawie pagination.total.
Zastosowanie keepPreviousData
Zapytanie korzysta z placeholderData: keepPreviousData, dzięki czemu przy zmianie strony tabela nie miga pustką — dotychczasowe wiersze pozostają widoczne aż do nadejścia nowych.
Kolumny
Kolumny definiuje się tak jak w QTable. Dwie nazwy mają jednak znaczenie specjalne:
| Nazwa kolumny | Efekt |
|---|---|
serial_no | BaseTable renderuje numer porządkowy, liczony globalnie przez wszystkie strony |
actions | BaseTable renderuje przyciski akcji, o ile prop :actions nie jest pusty |
Wartości formatuje prop format:
js
{ name: "created_at", field: "created_at", format: formatDate, sortable: true }Funkcje formatDate i formatDateOnly pochodzą z modules/core/utils/date.js i podlegają auto-importowi.
Pole wyliczane przyjmuje funkcję zamiast nazwy klucza:
js
{
name: "roles",
label: t("table.columns.role", 1),
field: (row) => row.roles.map((r) => r.name).join(", "),
align: "left",
}Własną komórkę tworzy slot body-cell-<nazwa>:
vue
<template #body-cell-icons="props">
<base-icons-td :props="props">
<base-visibility-badges :active="props.row.active" />
</base-icons-td>
</template>Kolumna bez pola field nie podlega sortowaniu
Ustawienie sortable: true przy sortowaniu serwerowym wysyła parametr order_by=<name> — nazwa kolumny musi więc być znana backendowi.
Filtry
Schemat filterSchema jest mapą przypisującą konfigurację do nazwy filtra:
js
// src/modules/blog/components/BlogArticlesTable.vue
filterSchema: {
search: { defaultValue: null },
role_slug: { defaultValue: null },
active: { type: "boolean", defaultValue: null },
tags: { type: "array", cast: "number", defaultValue: [] },
price: { type: "range", defaultValue: { min: null, max: null } },
dates: { type: "dateRange", defaultValue: [null, null] },
custom: { queryKey: "api_field_name", defaultValue: null },
special: { transform: (val) => ({ from: val.a, to: val.b }) },
}Pola konfiguracji
| Pole | Opis |
|---|---|
defaultValue | Wartość początkowa oraz wartość przywracana przez resetFilters() |
type | Jedna z wartości number, boolean, array, range, dateRange; pominięcie oznacza łańcuch znaków |
cast | Dla typu array przyjmuje "number" albo "boolean" i rzutuje elementy |
queryKey | Nazwa parametru w API odmienna od nazwy klucza filtra |
transform | Funkcja (wartość) => obiekt, zapewniająca pełną kontrolę nad parametrami |
Sposób przekazywania typów do API
| Typ | Wysyłany parametr |
|---|---|
| łańcuch znaków, liczba | key=wartość |
boolean | key=1 albo key=0 |
array | key=[...], pomijany przy pustej tablicy |
range | key[min]=... oraz key[max]=..., pomijany gdy obie wartości są puste |
dateRange | key= z tablicą dwuelementową w formacie JSON, pomijany gdy któraś data jest pusta |
Wartości puste, czyli null, "" oraz pusta tablica, nie są wysyłane.
Persystencja
| Tryb | Zachowanie |
|---|---|
"query", domyślny | Filtry i paginacja zapisywane w URL, z dwukierunkową synchronizacją; zapis następuje z debouncem 800 ms dla filtrów i 300 ms dla paginacji |
"storage" | Zapis w sessionStorage pod kluczem storageKey; filtry pod <storageKey>-filters, paginacja pod <storageKey>-pagination |
| dowolny inny | Wyłącznie pamięć, stan znika po przeładowaniu strony |
Wartości domyślne nie zaśmiecają adresu URL
Do adresu trafia jedynie to, co odbiega od domyślnych ustawień: page przy stronie większej od pierwszej oraz per_page, order_by i order_direction przy odejściu od defaultPagination. Odnośniki do list pozostają dzięki temu czytelne i nadają się do kopiowania.
Podpięcie filtrów w szablonie
Filtr wyszukiwania przekazuj przez v-model:filter — BaseTable ma wbudowane pole z debouncem 500 ms:
vue
<base-table v-model:filter="filters.search" :has-active-filters ... >Pozostałe filtry umieszczane są w slocie top-filters, zwijanym przyciskiem:
vue
<template #top-right="{ toggleFilters, areFiltersVisible }">
<base-table-filters-btns
:are-filters-visible="areFiltersVisible"
:has-active-filters="hasActiveFilters"
@toggle="toggleFilters"
@reset="resetFilters"
/>
</template>
<template #top-filters>
<base-select v-model="filters.role_slug" :options="roleOptions" />
</template>Akcje wiersza
Composable useCoreTable rejestruje dwie akcje domyślne:
| Akcja | Zachowanie |
|---|---|
edit | Przejście do ${alias}.edit z parametrami { id: row.id }; ikona feather_edit-3, order: 1 |
delete | Dialog BaseConfirmDialog w wariancie confirmDanger, a po potwierdzeniu żądanie DELETE ${resource}/${id} i unieważnienie listy; ikona feather_trash, order: 2 |
Opcja actions scala się z akcjami domyślnymi po nazwie:
js
// src/modules/blog/components/BlogArticlesTable.vue
useCoreTable({
resource: "/blog/articles",
alias: "blog.articles",
actions: {
// usunięcie akcji domyślnej
delete: false,
// dodatkowe parametry trasy edycji (klucz = nazwa parametru, wartość = pole rekordu)
edit: { params: { slug: "slug" } },
// własna akcja
preview: {
order: 0,
icon: { name: "feather_eye", color: "neutral-500" },
tooltip: t("blog.articles.tooltips.preview"),
callback: (row) => window.open(row.preview_url, "_blank"),
visibility: (row) => row.active,
disable: (row) => !row.editable,
},
},
});Pola akcji
| Pole | Typ | Opis |
|---|---|---|
name | string | Ustawiane automatycznie na podstawie klucza |
order | number | Kolejność wyświetlania, domyślnie 99 |
icon | object | Przekazywane do q-icon w postaci { name, color, size } |
label | string | Tekst wyświetlany obok ikony |
tooltip | string | Treść podpowiedzi |
to | object | (row) => location | Cel nawigacji |
callback | (row) => void | Akcja programistyczna, mająca pierwszeństwo przed to |
visibility | boolean | (row) => boolean | Decyduje o widoczności w danym wierszu |
disable | (row) => boolean | Decyduje o zablokowaniu akcji |
color, size, padding, btn | — | Przekazywane bezpośrednio do przycisku |
Pole callback ma pierwszeństwo przed to
Funkcja performAction sprawdza najpierw callback. Akcja definiująca oba pola zignoruje to przy kliknięciu — mimo że nadal ustawi ono atrybut odnośnika na przycisku.
Zaznaczanie i akcje masowe
W projekcie nie ma dedykowanego komponentu do akcji masowych — używa się natywnego mechanizmu QTable w połączeniu ze slotem top-after-filters:
vue
<!-- src/modules/blog/components/BlogArticlesTable.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>Wzorzec referencyjny
Pełna implementacja, obejmująca usuwanie, kopiowanie, przenoszenie oraz odświeżanie zaznaczonych pozycji, znajduje się w pliku src/modules/media/components/MediaFilesTable.vue.
Referencja komponentu BaseTable
Komponent zdefiniowany jest w src/modules/core/components/ui/BaseTable.vue i opakowuje QTable, przez co wszystkie propy i sloty QTable przechodzą dalej. Poniżej wyłącznie elementy dodatkowe — reszty szukaj w dokumentacji Quasara.
Propsy
| Prop | Typ | Domyślnie | Opis |
|---|---|---|---|
columns | Array | wymagany | Definicje kolumn |
rows | Array | [] | Wiersze |
name | String | null | Po ustawieniu stan otwarcia panelu filtrów zapisuje się w localStorage pod kluczem base-table-filters--<name> |
filter | String | "" | Wartość pola wyszukiwania, obsługiwana przez v-model:filter |
search | Boolean | true | Decyduje o wyświetleniu pola wyszukiwania |
serialNo | Boolean | true | — |
actions | Array | null | Akcje wiersza; każdy element musi posiadać pole name |
pagination | Object | {} | Obsługiwane przez v-model:pagination |
rowsPerPageOptions | Array | null | Domyślnie [3, 5, 7, 10, 15, 20, 25, 50, 100, 200] |
hideRowsPerPageSelect | Boolean | false | — |
showRowsPerPageInput | Boolean | false | — |
hasActiveFilters | Boolean | false | Początkowy stan panelu filtrów |
loading | Boolean | false | — |
hideTop | Boolean | false | Ukrywa cały pasek górny |
Zdarzenia
| Zdarzenie | Przekazywane dane |
|---|---|
update:filter | nowa wartość wyszukiwania |
update:pagination | nowy obiekt paginacji |
request | { pagination, filter } |
perform-action | { rowIndex, row, actionName } |
Sloty
| Slot | Scope | Zastosowanie |
|---|---|---|
top-right | { toggleFilters, areFiltersVisible } | Przyciski obok pola wyszukiwania |
top-before-filters | — | Obszar nad panelem filtrów |
top-filters | — | Panel filtrów, zwijany przyciskiem |
top-after-filters | — | Obszar pod filtrami, przeznaczony na akcje masowe |
base-page-top-right | — | Teleportowany do nagłówka strony, umieszcza się w nim przycisk tworzenia rekordu |
body-cell-<nazwa> | scope QTable | Własna komórka |
dowolny slot QTable | — | Przekazywany dalej |
Pochodzenie przycisku tworzenia rekordu w nagłówku strony
Komponent BaseTable teleportuje slot base-page-top-right do elementu #base-page-top-right znajdującego się w BasePage. Dzięki temu tabela samodzielnie umieszcza przycisk w nagłówku, bez przekazywania propsów przez stronę. Opisują to Layouty i strony.
Komponent ma ponadto cztery elementy wbudowane, których nie trzeba deklarować:
- numeracja
serial_no, liczona globalnie przez wszystkie strony, - kolumna
actions, - stopka paginacji z polem przejścia do wybranej strony,
BaseEmptyStateosadzony w slocieno-data.
Tabela ze zmianą kolejności
Listy przestawiane metodą przeciągnij i upuść obsługuje komponent BaseModelReorderTable z pliku src/modules/core/components/form/BaseModelReorderTable.vue:
| Prop | Typ | Domyślnie | Opis |
|---|---|---|---|
url | String | wymagany | Zasób do pobrania; zapis kierowany jest na ${url}/reorder |
fieldName | String | wymagany | Nazwa pola zawierającego tablicę identyfikatorów w żądaniu |
alias | String | — | Prefiks tras |
orderBy | String | "order" | Pole sortowania przy pobieraniu |
orderDirection | String | "ASC" | Kierunek sortowania |
Komponent udostępnia sloty header oraz item ze scope { element }.