Skip to content

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 ​

OpcjaDomyślnieOpis
resource—Ścieżka API, na przykład "/blog/articles"; może być typu ref
aliasnullPrefiks 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ęć
storageKeynullWymagany przy persistence: "storage"
immediatetrueOkreśla, czy dane pobierane są od razu
actions{}Nadpisania rejestru akcji wiersza, opisane poniżej
additionalParams{}Dodatkowe parametry żądania, mogą być reaktywne
deleteConfirmMessagenullWłasna treść komunikatu w dialogu usuwania

Zwracane wartości ​

KluczOpis
rowsZawartość data.results albo pusta tablica
rawDataPełna odpowiedź, przydatna przy dodatkowych danych z backendu
loadingSuma logiczna isPending, isFetching oraz isDeleting
paginationObiekt paginacji przeznaczony dla v-model:pagination
filtersReaktywny obiekt wartości filtrów
hasActiveFiltersInformacja, czy ustawiony został jakikolwiek filtr
resetFiltersPrzywraca wartości domyślne
onRequestHandler zdarzenia @request
fetchRowsRęczne odświeżenie danych, czyli refetch
actionsPosortowana lista akcji przeznaczona dla propa :actions
performActionHandler zdarzenia @perform-action

Kontrakt z serwerem ​

Wysyłane parametry:

page, per_page, order_by, order_direction (asc|desc), + parametry filtrów

Oczekiwana 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 kolumnyEfekt
serial_noBaseTable renderuje numer porządkowy, liczony globalnie przez wszystkie strony
actionsBaseTable 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 ​

PoleOpis
defaultValueWartość początkowa oraz wartość przywracana przez resetFilters()
typeJedna z wartości number, boolean, array, range, dateRange; pominięcie oznacza łańcuch znaków
castDla typu array przyjmuje "number" albo "boolean" i rzutuje elementy
queryKeyNazwa parametru w API odmienna od nazwy klucza filtra
transformFunkcja (wartość) => obiekt, zapewniająca pełną kontrolę nad parametrami

Sposób przekazywania typów do API ​

TypWysyłany parametr
łańcuch znaków, liczbakey=wartość
booleankey=1 albo key=0
arraykey=[...], pomijany przy pustej tablicy
rangekey[min]=... oraz key[max]=..., pomijany gdy obie wartości są puste
dateRangekey= 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 ​

TrybZachowanie
"query", domyślnyFiltry 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 innyWyłą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:

AkcjaZachowanie
editPrzejście do ${alias}.edit z parametrami { id: row.id }; ikona feather_edit-3, order: 1
deleteDialog 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 ​

PoleTypOpis
namestringUstawiane automatycznie na podstawie klucza
ordernumberKolejność wyświetlania, domyślnie 99
iconobjectPrzekazywane do q-icon w postaci { name, color, size }
labelstringTekst wyświetlany obok ikony
tooltipstringTreść podpowiedzi
toobject | (row) => locationCel nawigacji
callback(row) => voidAkcja programistyczna, mająca pierwszeństwo przed to
visibilityboolean | (row) => booleanDecyduje o widoczności w danym wierszu
disable(row) => booleanDecyduje 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 ​

PropTypDomyślnieOpis
columnsArraywymaganyDefinicje kolumn
rowsArray[]Wiersze
nameStringnullPo ustawieniu stan otwarcia panelu filtrów zapisuje się w localStorage pod kluczem base-table-filters--<name>
filterString""Wartość pola wyszukiwania, obsługiwana przez v-model:filter
searchBooleantrueDecyduje o wyświetleniu pola wyszukiwania
serialNoBooleantrue—
actionsArraynullAkcje wiersza; każdy element musi posiadać pole name
paginationObject{}Obsługiwane przez v-model:pagination
rowsPerPageOptionsArraynullDomyślnie [3, 5, 7, 10, 15, 20, 25, 50, 100, 200]
hideRowsPerPageSelectBooleanfalse—
showRowsPerPageInputBooleanfalse—
hasActiveFiltersBooleanfalsePoczątkowy stan panelu filtrów
loadingBooleanfalse—
hideTopBooleanfalseUkrywa cały pasek górny

Zdarzenia ​

ZdarzeniePrzekazywane dane
update:filternowa wartość wyszukiwania
update:paginationnowy obiekt paginacji
request{ pagination, filter }
perform-action{ rowIndex, row, actionName }

Sloty ​

SlotScopeZastosowanie
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 QTableWł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,
  • BaseEmptyState osadzony w slocie no-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:

PropTypDomyślnieOpis
urlStringwymaganyZasób do pobrania; zapis kierowany jest na ${url}/reorder
fieldNameStringwymaganyNazwa pola zawierającego tablicę identyfikatorów w żądaniu
aliasString—Prefiks tras
orderByString"order"Pole sortowania przy pobieraniu
orderDirectionString"ASC"Kierunek sortowania

Komponent udostępnia sloty header oraz item ze scope { element }.

Made by Moonbite