Skip to content

Data Views

A component for decorating a dataset with filters, pagination & sorting tools. Supports displaying that data in multiple formats, such as cards or tables.

^ These buttons are not part of DataView; they are only here for demonstration purposes.
Product Name
Brand
Category
Price per unit
Strain
No results

Table

Provide the variant="table" prop to <DataView> when using <Table> within DataView.

Product Name
Brand
Category
Price per unit
No results
vue
<script setup lang="ts">
  import { onMounted, ref } from 'vue';

  import { fetchProducts as apiGetProducts, Product } from '../../../src/components//DataView/DataView.fixtures';
  import DataView from '../../../src/components//DataView/DataView.vue';
  import DataViewFilters from '../../../src/components//DataViewFilters/DataViewFilters.vue';
  import DataViewToolbar from '../../../src/components//DataViewToolbar/DataViewToolbar.vue';
  import Table from '../../../src/components//Table/Table.vue';
  import TableCell from '../../../src/components//TableCell/TableCell.vue';
  import TableHeaderCell from '../../../src/components//TableHeaderCell/TableHeaderCell.vue';
  import TableRow from '../../../src/components//TableRow/TableRow.vue';
  import { money } from '../../../src/utils/i18n';

  const PAGE_SIZE = 4;

  const dataViewRef = ref<InstanceType<typeof DataView>>();
  const products = ref<Product[]>([]);
  const isLoadingProducts = ref(false);
  const totalProductCount = ref(products.value.length);

  async function fetchProducts() {
    if (!dataViewRef.value || isLoadingProducts.value) {
      return;
    }

    try {
      isLoadingProducts.value = true;

      const response = await apiGetProducts({
        params: {
          page: dataViewRef.value.page || 1,
          pageSize: dataViewRef.value.pageSize || PAGE_SIZE,
          ordering: dataViewRef.value.ordering,
          search: dataViewRef.value.search,
        },
      });

      products.value = response.results;
      totalProductCount.value = response.count;
    } finally {
      isLoadingProducts.value = false;
    }
  }

  onMounted(() => {
    fetchProducts();
  });
</script>

<template>
  <DataView
    ref="dataViewRef"
    variant="table"
    :data="products"
    :page-size="PAGE_SIZE"
    :total-data-count="totalProductCount"
    :is-loading="isLoadingProducts"
    :is-empty="products.length === 0"
    @update="fetchProducts"
  >
    <DataViewFilters
      :search-bar-props="{
        hintText: 'Search for Product Name',
        placeholder: 'Ex: Citrus',
      }"
    />

    <DataViewToolbar />

    <Table>
      <template #head>
        <tr>
          <TableHeaderCell class="tw-min-w-[300px]">Product Name</TableHeaderCell>
          <TableHeaderCell class="tw-min-w-[140px]">Brand</TableHeaderCell>
          <TableHeaderCell>Category</TableHeaderCell>
          <TableHeaderCell>Price per unit</TableHeaderCell>
        </tr>
      </template>
      <template #body>
        <TableRow v-for="product in products" :key="product.id">
          <TableCell>
            <div class="tw-flex tw-items-center">
              <img :src="product.featured_image" width="60" class="tw-mr-3 tw-rounded" />
              {{ product.name }}
            </div>
          </TableCell>
          <TableCell>
            {{ product.brand.name }}
          </TableCell>
          <TableCell>
            {{ product.category.name }}
          </TableCell>
          <TableCell>
            {{ money(product.price_per_unit) }}
          </TableCell>
        </TableRow>
      </template>
    </Table>
  </DataView>
</template>

When Table Selection is enabled within a <DataView>, the "Select All" checkmark will be moved into <DataViewToolbar> if toolbar is available to reduce clutter over the table header. Please note that all the selection props from TableHeader should live on the toolbar in this instance.

If there's no Toolbar within the Data View or <Table> is rendered standalone, please refer to <Table> configurations. For more context on using selection, refer to useSelection composable.

PLEASE NOTE THAT it is extremely recommended to clear selection using unselectAll() when paginating to avoid the risk of unintentional destructive actions as per example below:

Product Name
Brand
Category
Price per unit
No results
vue
<script setup lang="ts">
  import { onMounted, ref } from 'vue';

  import Button from '../../../src/components//Button/Button.vue';
  import { fetchProducts as apiGetProducts, Product } from '../../../src/components//DataView/DataView.fixtures';
  import DataView from '../../../src/components//DataView/DataView.vue';
  import DataViewFilters from '../../../src/components//DataViewFilters/DataViewFilters.vue';
  import DataViewSortButton from '../../../src/components//DataViewSortButton/DataViewSortButton.vue';
  import DataViewToolbar from '../../../src/components//DataViewToolbar/DataViewToolbar.vue';
  import Dialog from '../../../src/components//Dialog/Dialog.vue';
  import IconLabel from '../../../src/components//IconLabel/IconLabel.vue';
  import Table from '../../../src/components//Table/Table.vue';
  import TableCell from '../../../src/components//TableCell/TableCell.vue';
  import TableHeaderCell from '../../../src/components//TableHeaderCell/TableHeaderCell.vue';
  import TableHeaderRow from '../../../src/components//TableHeaderRow/TableHeaderRow.vue';
  import TableRow from '../../../src/components//TableRow/TableRow.vue';
  import useModals from '../../../src/composables///useModals/useModals';
  import useSelection from '../../../src/composables//useSelection/useSelection';
  import { money } from '../../../src/utils/i18n';
  import EditProductModal from './modals/EditProductModal.vue';

  const PAGE_SIZE = 4;

  const sortOptions = [
    { id: 'product_name', labelAsc: 'Product Name (A-Z)', labelDesc: 'Product Name (Z-A)' },
    { id: 'category_name', labelAsc: 'Category Name (A-Z)', labelDesc: 'Category Name (Z-A)' },
    {
      id: 'price_per_unit_amount',
      labelAsc: 'Price Per Unit (Low to High)',
      labelDesc: 'Price Per Unit (High to Low)',
    },
    { id: 'strain_classification', labelAsc: 'Strain (A-Z)', labelDesc: 'Strain (Z-A)' },
  ];

  const dataViewRef = ref<InstanceType<typeof DataView>>();
  const products = ref<Product[]>([]);
  const isLoadingProducts = ref(false);
  const totalProductCount = ref(products.value.length);
  const deleteDialog = ref(false);

  const dataRef = ref(products);
  const { isSelected, selectToggle, selectToggleAll, unselectAll, allSelected, selectedItems, someSelected } =
    useSelection({
      items: dataRef,
    });

  const modal = useModals();

  async function fetchProducts() {
    if (!dataViewRef.value || isLoadingProducts.value) {
      return;
    }

    try {
      isLoadingProducts.value = true;

      const response = await apiGetProducts({
        params: {
          page: dataViewRef.value.page || 1,
          pageSize: dataViewRef.value.pageSize || PAGE_SIZE,
          ordering: dataViewRef.value.ordering,
          search: dataViewRef.value.search,
        },
      });

      products.value = response.results;
      totalProductCount.value = response.count;

      unselectAll(); 
    } finally {
      isLoadingProducts.value = false;
    }
  }

  function deleteProducts() {
    products.value = products.value.filter(
      (product) => !selectedItems.value.map((item) => item.id).includes(product.id),
    );

    unselectAll();
    deleteDialog.value = false;
  }

  function editModal() {
    modal.open({
      component: EditProductModal,
      name: 'Edit Products',
      attributes: {
        products: selectedItems.value,
        onDismiss: () => modal.close(),
        onSave: (updatedProducts: Product[]) => {
          products.value = products.value.map((product) => {
            return updatedProducts.find((updated) => updated.id === product.id) || product;
          });

          modal.close();
        },
      },
    });
  }

  onMounted(() => {
    fetchProducts();
  });
</script>

<template>
  <DataView
    ref="dataViewRef"
    variant="table"
    :data="products"
    :page-size="PAGE_SIZE"
    :total-data-count="totalProductCount"
    :is-loading="isLoadingProducts"
    :is-empty="products.length === 0"
    @update="fetchProducts"
  >
    <DataViewFilters
      :search-bar-props="{
        hintText: 'Search for Product Name',
        placeholder: 'Ex: Citrus',
      }"
    />

    <DataViewToolbar :all-rows-selected="allSelected" :some-rows-selected="someSelected" @select="selectToggleAll">
      <DataViewSortButton :sort-options="sortOptions" />
      <Button class="tw-text-blue-500" icon-label><IconLabel icon="plus" stacked>Add</IconLabel></Button>
      <Button v-if="someSelected" class="tw-text-blue-500" icon-label @click="deleteDialog = true">
        <IconLabel icon="trashcan" stacked>Delete</IconLabel>
      </Button>
      <Button v-if="someSelected" class="tw-text-blue-500" icon-label @click="editModal">
        <IconLabel icon="edit" stacked>Edit</IconLabel>
      </Button>
      <Button class="tw-text-blue-500" icon-label><IconLabel icon="download" stacked>Download</IconLabel></Button>
    </DataViewToolbar>

    <Table is-selectable>
      <template #head>
        <TableHeaderRow>
          <TableHeaderCell class="tw-min-w-[300px]">Product Name</TableHeaderCell>
          <TableHeaderCell class="tw-min-w-[140px]">Brand</TableHeaderCell>
          <TableHeaderCell>Category</TableHeaderCell>
          <TableHeaderCell>Price per unit</TableHeaderCell>
        </TableHeaderRow>
      </template>
      <template #body>
        <TableRow
          v-for="product in products"
          :key="product.id"
          :is-selected="isSelected(product)"
          @update:is-selected="selectToggle(product)"
        >
          <TableCell>
            <div class="tw-flex tw-items-center">
              <img :src="product.featured_image" width="60" class="tw-mr-3 tw-rounded" />
              {{ product.name }}
            </div>
          </TableCell>
          <TableCell>
            {{ product.brand.name }}
          </TableCell>
          <TableCell>
            {{ product.category.name }}
          </TableCell>
          <TableCell>
            {{ money(product.price_per_unit) }}
          </TableCell>
        </TableRow>
      </template>
    </Table>
  </DataView>

  <Dialog
    cancel-text="Cancel"
    confirm-text="Destroy"
    danger-zone
    :description="`You're about to remove ${selectedItems.length} products. Are you sure?`"
    header="Destroy record?"
    :open="deleteDialog"
    @cancel="deleteDialog = false"
    @confirm="deleteProducts"
  />
</template>

Following this behavior both simplifies the data management throughout paginated results as well as removes the need a user has to remember which elements were previously selected or not.

Sorting

The <DataViewToolbar /> and <DataViewSortButton /> comeponents can be used to add a "Sort By" button in the toolbar.

Start by creating a list of sorting options and then provide that list to <DataViewSortButton>.

ts
const sortOptions: SortOption[] = [
  { id: 'product_name', labelAsc: 'Product Name (A-Z)', labelDesc: 'Product Name (Z-A)' },
  { id: 'category_name', labelAsc: 'Category Name (A-Z)', labelDesc: 'Category Name (Z-A)' },
];
template
<DataViewSortButton :sort-options="sortOptions" />

When used with <Table>, the <TableHeaderCell /> component can receive a sort-id prop whose value should correspond to the id of one of the sortOptions. To see a full code example, click "Show Code" below the demo.

Product Name
Brand
Category
Price per unit
No results
vue
<script setup lang="ts">
  import { onMounted, ref } from 'vue';

  import { fetchProducts as apiGetProducts, Product } from '../../../src/components//DataView/DataView.fixtures';
  import DataView from '../../../src/components//DataView/DataView.vue';
  import DataViewSortButton from '../../../src/components//DataViewSortButton/DataViewSortButton.vue';
  import DataViewToolbar from '../../../src/components//DataViewToolbar/DataViewToolbar.vue';
  import Table from '../../../src/components//Table/Table.vue';
  import TableCell from '../../../src/components//TableCell/TableCell.vue';
  import TableHeaderCell from '../../../src/components//TableHeaderCell/TableHeaderCell.vue';
  import TableRow from '../../../src/components//TableRow/TableRow.vue';
  import { money } from '../../../src/utils/i18n';

  const PAGE_SIZE = 4;

  const sortOptions = [
    { id: 'product_name', labelAsc: 'Product Name (A-Z)', labelDesc: 'Product Name (Z-A)' },
    { id: 'category_name', labelAsc: 'Category Name (A-Z)', labelDesc: 'Category Name (Z-A)' },
    {
      id: 'price_per_unit_amount',
      labelAsc: 'Price Per Unit (Low to High)',
      labelDesc: 'Price Per Unit (High to Low)',
    },
    { id: 'strain_classification', labelAsc: 'Strain (A-Z)', labelDesc: 'Strain (Z-A)' },
  ];
  const dataViewRef = ref<InstanceType<typeof DataView>>();
  const products = ref<Product[]>([]);
  const isLoadingProducts = ref(false);
  const totalProductCount = ref(products.value.length);

  async function fetchProducts() {
    if (!dataViewRef.value || isLoadingProducts.value) {
      return;
    }

    try {
      isLoadingProducts.value = true;

      const response = await apiGetProducts({
        params: {
          page: dataViewRef.value.page || 1,
          pageSize: dataViewRef.value.pageSize || PAGE_SIZE,
          ordering: dataViewRef.value.ordering,
          search: dataViewRef.value.search,
        },
      });

      products.value = response.results;
      totalProductCount.value = response.count;
    } finally {
      isLoadingProducts.value = false;
    }
  }

  onMounted(() => {
    fetchProducts();
  });
</script>

<template>
  <DataView
    ref="dataViewRef"
    variant="table"
    :data="products"
    :page-size="4"
    :total-data-count="totalProductCount"
    :is-loading="isLoadingProducts"
    :is-empty="products.length === 0"
    @update="fetchProducts"
  >
    <DataViewToolbar>
      <DataViewSortButton :sort-options="sortOptions" />
    </DataViewToolbar>

    <Table>
      <template #head>
        <tr>
          <TableHeaderCell class="tw-min-w-[300px]" sort-id="product_name">Product Name</TableHeaderCell>
          <TableHeaderCell class="tw-min-w-[140px]">Brand</TableHeaderCell>
          <TableHeaderCell sort-id="category_name">Category</TableHeaderCell>
          <TableHeaderCell sort-id="price_per_unit_amount">Price per unit</TableHeaderCell>
        </tr>
      </template>
      <template #body>
        <TableRow v-for="product in products" :key="product.id">
          <TableCell>
            <div class="tw-flex tw-items-center">
              <img :src="product.featured_image" width="60" class="tw-mr-3 tw-rounded" />
              {{ product.name }}
            </div>
          </TableCell>
          <TableCell>
            {{ product.brand.name }}
          </TableCell>
          <TableCell>
            {{ product.category.name }}
          </TableCell>
          <TableCell>
            {{ money(product.price_per_unit) }}
          </TableCell>
        </TableRow>
      </template>
    </Table>
  </DataView>
</template>

Filters

The <DataViewFilters /> component can be used with <FilterDropdown /> and <FilterDrawerItem /> to create custom filters.

TIP

The useDataViewFilters composable provides utilities that can be used with <DataViewFilters>, <FilterDropdown>, and <FilterDrawerItem>. Click "Show Code" below the demo to see how it's used.

INFO

The following example applies some filters by default.

Product Name
Brand
Category
Price per unit
Strain
No results
vue
<script setup lang="ts">
  import { onMounted, ref } from 'vue';

  import {
    brandOptions,
    fetchProducts as apiGetProducts,
    Product,
    strainOptions,
  } from '../../../src/components/DataView/DataView.fixtures';
  import DataView from '../../../src/components/DataView/DataView.vue';
  import DataViewFilters, {
    useFilters,
    UseFiltersSchema,
  } from '../../../src/components/DataViewFilters/DataViewFilters.vue';
  import DataViewToolbar from '../../../src/components/DataViewToolbar/DataViewToolbar.vue';
  import FilterDrawerItem from '../../../src/components/FilterDrawerItem/FilterDrawerItem.vue';
  import FilterDropdown from '../../../src/components/FilterDropdown/FilterDropdown.vue';
  import Input from '../../../src/components/Input/Input.vue';
  import Select from '../../../src/components/Select/Select.vue';
  import Table from '../../../src/components/Table/Table.vue';
  import TableCell from '../../../src/components/TableCell/TableCell.vue';
  import TableHeaderCell from '../../../src/components/TableHeaderCell/TableHeaderCell.vue';
  import TableRow from '../../../src/components/TableRow/TableRow.vue';
  import { money } from '../../../src/utils/i18n';

  const PAGE_SIZE = 4;

  const dataViewRef = ref<InstanceType<typeof DataView>>();
  const products = ref<Product[]>([]);
  const isLoadingProducts = ref(false);
  const totalProductCount = ref(products.value.length);

  // #region Filters
  interface FilterValues {
    strain?: (typeof strainOptions)[number];
    brand?: (typeof brandOptions)[number];
    priceMin?: string | number;
    priceMax?: string | number;
  }

  type FilterGroups = 'type' | 'price';

  const filterSchema: UseFiltersSchema<FilterValues, FilterGroups> = {
    strain: {
      group: 'type',
      defaultValue: strainOptions[1],
    },
    brand: {
      group: 'type',
    },
    priceMin: {
      group: 'price',
    },
    priceMax: {
      group: 'price',
      defaultValue: 2000,
    },
  };

  const useFiltersInstance = useFilters({ schema: filterSchema, dataViewRef });

  const { workingFilters, appliedFilters } = useFiltersInstance;
  // #endregion

  async function fetchProducts() {
    if (!dataViewRef.value || isLoadingProducts.value) {
      return;
    }

    try {
      isLoadingProducts.value = true;

      const response = await apiGetProducts({
        params: {
          page: dataViewRef.value.page || 1,
          pageSize: dataViewRef.value.pageSize || PAGE_SIZE,
          ordering: dataViewRef.value.ordering,
          search: dataViewRef.value.search,
          strain: appliedFilters.value.strain?.id,
          brandId: appliedFilters.value.brand?.id,
          priceMin: appliedFilters.value.priceMin,
          priceMax: appliedFilters.value.priceMax,
        },
      });

      products.value = response.results;
      totalProductCount.value = response.count;
    } finally {
      isLoadingProducts.value = false;
    }
  }

  onMounted(() => {
    fetchProducts();
  });
</script>

<template>
  <DataView
    ref="dataViewRef"
    variant="table"
    :data="products"
    :page-size="4"
    :total-data-count="totalProductCount"
    :is-loading="isLoadingProducts"
    :is-empty="products.length === 0"
    @update="fetchProducts()"
  >
    <DataViewFilters
      :use-filters-instance="useFiltersInstance"
      :search-bar-props="{
        hintText: 'Search for Product Name',
        placeholder: 'Ex: Citrus',
      }"
    >
      <FilterDropdown label="Type" group="type">
        <Select v-model="workingFilters.strain" label="Strain" single add-bottom-space :options="strainOptions" />
        <Select v-model="workingFilters.brand" label="Brand name" single add-bottom-space :options="brandOptions" />
      </FilterDropdown>
      <FilterDropdown label="Price" group="price">
        <Input v-model="workingFilters.priceMin" label="Minimum Price" type="number" add-bottom-space />
        <Input v-model="workingFilters.priceMax" label="Maximum Price" type="number" add-bottom-space />
      </FilterDropdown>
      <template #drawer>
        <div class="tw-p-4">
          <FilterDrawerItem title="Type" group="type">
            <Select v-model="workingFilters.strain" label="Strain" single add-bottom-space :options="strainOptions" />
            <Select v-model="workingFilters.brand" label="Brand name" single add-bottom-space :options="brandOptions" />
          </FilterDrawerItem>
          <FilterDrawerItem title="Price" group="price">
            <Input v-model="workingFilters.priceMin" label="Minimum Price" type="number" add-bottom-space />
            <Input v-model="workingFilters.priceMax" label="Maximum Price" type="number" add-bottom-space />
          </FilterDrawerItem>
        </div>
      </template>
    </DataViewFilters>

    <DataViewToolbar />

    <Table>
      <template #head>
        <tr>
          <TableHeaderCell class="tw-min-w-[300px]" sort-id="product_name">Product Name</TableHeaderCell>
          <TableHeaderCell class="tw-min-w-[140px]">Brand</TableHeaderCell>
          <TableHeaderCell sort-id="category_name">Category</TableHeaderCell>
          <TableHeaderCell sort-id="price_per_unit_amount">Price per unit</TableHeaderCell>
          <TableHeaderCell sort-id="strain_classification">Strain</TableHeaderCell>
        </tr>
      </template>
      <template #body>
        <TableRow v-for="product in products" :key="product.id">
          <TableCell>
            <div class="tw-flex tw-items-center">
              <img :src="product.featured_image" width="60" class="tw-mr-3 tw-rounded" />
              {{ product.name }}
            </div>
          </TableCell>
          <TableCell>
            {{ product.brand.name }}
          </TableCell>
          <TableCell>
            {{ product.category.name }}
          </TableCell>
          <TableCell>
            {{ money(product.price_per_unit) }}
          </TableCell>
          <TableCell class="show-empty">
            {{ product.strain_classification }}
          </TableCell>
        </TableRow>
      </template>
    </Table>
  </DataView>
</template>

Validation

The useValidation composable can be used with the useFilters composable to add validation to <DataViewFilters> fields.

Product Name
Brand
Category
Price per unit
Strain
No results
vue
<script setup lang="ts">
  import { computed, onMounted, ref } from 'vue';

  import {
    brandOptions,
    fetchProducts as apiGetProducts,
    Product,
    strainOptions,
  } from '../../../src/components/DataView/DataView.fixtures';
  import DataView from '../../../src/components/DataView/DataView.vue';
  import DataViewFilters, {
    useFilters,
    UseFiltersSchema,
  } from '../../../src/components/DataViewFilters/DataViewFilters.vue';
  import DataViewToolbar from '../../../src/components/DataViewToolbar/DataViewToolbar.vue';
  import FilterDrawerItem from '../../../src/components/FilterDrawerItem/FilterDrawerItem.vue';
  import FilterDropdown from '../../../src/components/FilterDropdown/FilterDropdown.vue';
  import Input from '../../../src/components/Input/Input.vue';
  import Select from '../../../src/components/Select/Select.vue';
  import Table from '../../../src/components/Table/Table.vue';
  import TableCell from '../../../src/components/TableCell/TableCell.vue';
  import TableHeaderCell from '../../../src/components/TableHeaderCell/TableHeaderCell.vue';
  import TableRow from '../../../src/components/TableRow/TableRow.vue';
  import minValue from '../../../src/composables/useValidation/ruleFactories/minValue';
  import useValidation from '../../../src/composables/useValidation/useValidation';
  import { ValidationRules } from '../../../src/composables/useValidation/useValidation.types';
  import { money } from '../../../src/utils/i18n';

  const PAGE_SIZE = 4;

  const dataViewRef = ref<InstanceType<typeof DataView>>();
  const products = ref<Product[]>([]);
  const isLoadingProducts = ref(false);
  const totalProductCount = ref(products.value.length);

  // #region Filters
  interface FilterValues {
    strain?: (typeof strainOptions)[number];
    brand?: (typeof brandOptions)[number];
    priceMin?: string | number;
    priceMax?: string | number;
  }

  type FilterGroups = 'type' | 'price';

  const filterSchema: UseFiltersSchema<FilterValues, FilterGroups> = {
    strain: {
      group: 'type',
    },
    brand: {
      group: 'type',
    },
    priceMin: {
      group: 'price',
    },
    priceMax: {
      group: 'price',
    },
  };

  const useFiltersInstance = useFilters({ schema: filterSchema, dataViewRef });

  const { workingFilters, appliedFilters, applyFilters } = useFiltersInstance;

  interface ValidationValues {
    priceMin?: FilterValues['priceMin'];
    priceMax?: FilterValues['priceMax'];
  }

  const validationRules: ValidationRules<ValidationValues> = {
    priceMin: [minValue({ min: 0 })],
    priceMax: [
      minValue({
        min: () => {
          let min: number | undefined;

          if (typeof workingFilters.value.priceMin === 'string') {
            min = parseFloat(workingFilters.value.priceMin); // parseFloat() will return NaN if the string is not a number whereas `Number()` will return 0.
          } else {
            min = workingFilters.value.priceMin;
          }

          return min ?? -Infinity;
        },
      }),
    ],
  };

  const validation = useValidation<ValidationValues>({
    rules: validationRules,
    values: computed(() => ({
      priceMin: workingFilters.value.priceMin,
      priceMax: workingFilters.value.priceMax,
    })),
  });

  async function onApplyFilters() {
    await validation.validate();

    if (validation.hasErrors) {
      return { preventDismiss: true };
    }

    applyFilters();
    validation.setAllUntouched();
  }
  // #endregion

  async function fetchProducts() {
    if (!dataViewRef.value || isLoadingProducts.value) {
      return;
    }

    try {
      isLoadingProducts.value = true;

      const response = await apiGetProducts({
        params: {
          page: dataViewRef.value.page || 1,
          pageSize: dataViewRef.value.pageSize || PAGE_SIZE,
          ordering: dataViewRef.value.ordering,
          search: dataViewRef.value.search,
          strain: appliedFilters.value.strain?.id,
          brandId: appliedFilters.value.brand?.id,
          priceMin: appliedFilters.value.priceMin,
          priceMax: appliedFilters.value.priceMax,
        },
      });

      products.value = response.results;
      totalProductCount.value = response.count;
    } finally {
      isLoadingProducts.value = false;
    }
  }

  onMounted(() => {
    fetchProducts();
  });
</script>

<template>
  <DataView
    ref="dataViewRef"
    variant="table"
    :data="products"
    :page-size="PAGE_SIZE"
    :total-data-count="totalProductCount"
    :is-loading="isLoadingProducts"
    :is-empty="products.length === 0"
    @update="fetchProducts()"
  >
    <DataViewFilters
      :use-filters-instance="useFiltersInstance"
      :search-bar-props="{
        hintText: 'Search for Product Name',
        placeholder: 'Ex: Citrus',
      }"
      @apply="onApplyFilters()"
      @reset-all="validation.setAllUntouched()"
    >
      <FilterDropdown label="Type" group="type" @apply="onApplyFilters" @reset="validation.setAllUntouched()">
        <Select v-model="workingFilters.strain" label="Strain" single add-bottom-space :options="strainOptions" />
        <Select v-model="workingFilters.brand" label="Brand name" single add-bottom-space :options="brandOptions" />
      </FilterDropdown>
      <FilterDropdown label="Price" group="price" @apply="onApplyFilters" @reset="validation.setAllUntouched()">
        <Input
          v-model="workingFilters.priceMin"
          label="Minimum Price"
          type="number"
          :error-text="validation.getError('priceMin')"
          add-bottom-space
          @blur="validation.touch('priceMin')"
        />
        <Input
          v-model="workingFilters.priceMax"
          label="Maximum Price"
          type="number"
          :error-text="validation.getError('priceMax')"
          add-bottom-space
          @blur="validation.touch('priceMax')"
        />
      </FilterDropdown>
      <template #drawer>
        <div class="tw-p-4">
          <FilterDrawerItem title="Type" group="type">
            <Select v-model="workingFilters.strain" label="Strain" single add-bottom-space :options="strainOptions" />
            <Select v-model="workingFilters.brand" label="Brand name" single add-bottom-space :options="brandOptions" />
          </FilterDrawerItem>
          <FilterDrawerItem title="Price" group="price">
            <Input
              v-model="workingFilters.priceMin"
              label="Minimum Price"
              type="number"
              :error-text="validation.getError('priceMin')"
              add-bottom-space
              @blur="validation.touch('priceMin')"
            />
            <Input
              v-model="workingFilters.priceMax"
              label="Maximum Price"
              type="number"
              :error-text="validation.getError('priceMax')"
              add-bottom-space
              @blur="validation.touch('priceMax')"
            />
          </FilterDrawerItem>
        </div>
      </template>
    </DataViewFilters>

    <DataViewToolbar />

    <Table>
      <template #head>
        <tr>
          <TableHeaderCell class="tw-min-w-[300px]" sort-id="product_name">Product Name</TableHeaderCell>
          <TableHeaderCell class="tw-min-w-[140px]">Brand</TableHeaderCell>
          <TableHeaderCell sort-id="category_name">Category</TableHeaderCell>
          <TableHeaderCell sort-id="price_per_unit_amount">Price per unit</TableHeaderCell>
          <TableHeaderCell sort-id="strain_classification">Strain</TableHeaderCell>
        </tr>
      </template>
      <template #body>
        <TableRow v-for="product in products" :key="product.id">
          <TableCell>
            <div class="tw-flex tw-items-center">
              <img :src="product.featured_image" width="60" class="tw-mr-3 tw-rounded" />
              {{ product.name }}
            </div>
          </TableCell>
          <TableCell>
            {{ product.brand.name }}
          </TableCell>
          <TableCell>
            {{ product.category.name }}
          </TableCell>
          <TableCell>
            {{ money(product.price_per_unit) }}
          </TableCell>
          <TableCell class="show-empty">
            {{ product.strain_classification }}
          </TableCell>
        </TableRow>
      </template>
    </Table>
  </DataView>
</template>

Nested filters

To emphasize the filter group names, add the drawer-style="nested" prop to <DataViewFilters>. This configuration displays only the group names when the filter drawer is opened; the user needs to click a group name to view and edit specific filter fields.

Product Name
Brand
Category
Price per unit
Strain
No results
vue
<script setup lang="ts">
  import { onMounted, ref } from 'vue';

  import {
    brandOptions,
    fetchProducts as apiGetProducts,
    Product,
    strainOptions,
  } from '../../../src/components/DataView/DataView.fixtures';
  import DataView from '../../../src/components/DataView/DataView.vue';
  import DataViewFilters, {
    useFilters,
    UseFiltersSchema,
  } from '../../../src/components/DataViewFilters/DataViewFilters.vue';
  import DataViewToolbar from '../../../src/components/DataViewToolbar/DataViewToolbar.vue';
  import FilterDrawerItem from '../../../src/components/FilterDrawerItem/FilterDrawerItem.vue';
  import FilterDropdown from '../../../src/components/FilterDropdown/FilterDropdown.vue';
  import Input from '../../../src/components/Input/Input.vue';
  import Select from '../../../src/components/Select/Select.vue';
  import Table from '../../../src/components/Table/Table.vue';
  import TableCell from '../../../src/components/TableCell/TableCell.vue';
  import TableHeaderCell from '../../../src/components/TableHeaderCell/TableHeaderCell.vue';
  import TableRow from '../../../src/components/TableRow/TableRow.vue';
  import { money } from '../../../src/utils/i18n';

  const PAGE_SIZE = 4;

  const dataViewRef = ref<InstanceType<typeof DataView>>();
  const products = ref<Product[]>([]);
  const isLoadingProducts = ref(false);
  const totalProductCount = ref(products.value.length);

  // #region Filters
  interface FilterValues {
    strain?: (typeof strainOptions)[number];
    brand?: (typeof brandOptions)[number];
    priceMin?: string | number;
    priceMax?: string | number;
  }

  type FilterGroups = 'type' | 'price';

  const activeFilterGroup = ref<undefined | FilterGroups>(undefined);

  const filterSchema: UseFiltersSchema<FilterValues, FilterGroups> = {
    strain: {
      group: 'type',
      defaultValue: strainOptions[1],
    },
    brand: {
      group: 'type',
    },
    priceMin: {
      group: 'price',
    },
    priceMax: {
      group: 'price',
      defaultValue: 2000,
    },
  };

  const useFiltersInstance = useFilters({ schema: filterSchema, dataViewRef });

  const { workingFilters, appliedFilters } = useFiltersInstance;
  // #endregion

  async function fetchProducts() {
    if (!dataViewRef.value || isLoadingProducts.value) {
      return;
    }

    try {
      isLoadingProducts.value = true;

      const response = await apiGetProducts({
        params: {
          page: dataViewRef.value.page || 1,
          pageSize: dataViewRef.value.pageSize || PAGE_SIZE,
          ordering: dataViewRef.value.ordering,
          search: dataViewRef.value.search,
          strain: appliedFilters.value.strain?.id,
          brandId: appliedFilters.value.brand?.id,
          priceMin: appliedFilters.value.priceMin,
          priceMax: appliedFilters.value.priceMax,
        },
      });

      products.value = response.results;
      totalProductCount.value = response.count;
    } finally {
      isLoadingProducts.value = false;
    }
  }

  onMounted(() => {
    fetchProducts();
  });
</script>

<template>
  <DataView
    ref="dataViewRef"
    variant="table"
    :data="products"
    :page-size="4"
    :total-data-count="totalProductCount"
    :is-loading="isLoadingProducts"
    :is-empty="products.length === 0"
    @update="fetchProducts()"
  >
    <DataViewFilters
      :use-filters-instance="useFiltersInstance"
      :active-group="activeFilterGroup"
      :search-bar-props="{
        hintText: 'Search for Product Name',
        placeholder: 'Ex: Citrus',
      }"
      drawer-style="nested"
      @previous="activeFilterGroup = undefined"
      @open-drawer="activeFilterGroup = undefined"
    >
      <FilterDropdown label="Type" group="type">
        <Select v-model="workingFilters.strain" label="Strain" single add-bottom-space :options="strainOptions" />
        <Select v-model="workingFilters.brand" label="Brand name" single add-bottom-space :options="brandOptions" />
      </FilterDropdown>
      <FilterDropdown label="Price" group="price">
        <Input v-model="workingFilters.priceMin" label="Minimum Price" type="number" add-bottom-space />
        <Input v-model="workingFilters.priceMax" label="Maximum Price" type="number" add-bottom-space />
      </FilterDropdown>
      <template #drawer>
        <div class="tw-p-4">
          <template v-if="!activeFilterGroup">
            <FilterDrawerItem title="Type" group="type" @navigate="activeFilterGroup = 'type'" />
            <FilterDrawerItem title="Price" group="price" @navigate="activeFilterGroup = 'price'" />
          </template>
          <template v-else-if="activeFilterGroup === 'type'">
            <Select v-model="workingFilters.strain" label="Strain" single add-bottom-space :options="strainOptions" />
            <Select v-model="workingFilters.brand" label="Brand name" single add-bottom-space :options="brandOptions" />
          </template>
          <template v-else-if="activeFilterGroup === 'price'">
            <Input v-model="workingFilters.priceMin" label="Minimum Price" type="number" add-bottom-space />
            <Input v-model="workingFilters.priceMax" label="Maximum Price" type="number" add-bottom-space />
          </template>
        </div>
      </template>
    </DataViewFilters>

    <DataViewToolbar />

    <Table>
      <template #head>
        <tr>
          <TableHeaderCell class="tw-min-w-[300px]" sort-id="product_name">Product Name</TableHeaderCell>
          <TableHeaderCell class="tw-min-w-[140px]">Brand</TableHeaderCell>
          <TableHeaderCell sort-id="category_name">Category</TableHeaderCell>
          <TableHeaderCell sort-id="price_per_unit_amount">Price per unit</TableHeaderCell>
          <TableHeaderCell sort-id="strain_classification">Strain</TableHeaderCell>
        </tr>
      </template>
      <template #body>
        <TableRow v-for="product in products" :key="product.id">
          <TableCell>
            <div class="tw-flex tw-items-center">
              <img :src="product.featured_image" width="60" class="tw-mr-3 tw-rounded" />
              {{ product.name }}
            </div>
          </TableCell>
          <TableCell>
            {{ product.brand.name }}
          </TableCell>
          <TableCell>
            {{ product.category.name }}
          </TableCell>
          <TableCell>
            {{ money(product.price_per_unit) }}
          </TableCell>
          <TableCell class="show-empty">
            {{ product.strain_classification }}
          </TableCell>
        </TableRow>
      </template>
    </Table>
  </DataView>
</template>

Within a <Module>

When rendering a DataView table within a Module, the design changes a bit. There's no more spacing between the DataView components, bottom pagination is hidden, rounded corners go away in favor of Module's and a few other design tweaks.

We could compose this and make the appropriate changes with style overrides & props, however since this is a common pattern in our design system, we have built in the ability to achieve this design by simply specifying the table variant on your Module component. All the children of DataView & Table components will adjust accordingly.

Some Data Table

We're rendering a DataView Table within a Module

Product Name
Brand
Category
Price per unit
No results
vue
<script setup lang="ts">
  import { onMounted, ref } from 'vue';

  import { fetchProducts as apiGetProducts, Product } from '../../../src/components/DataView/DataView.fixtures';
  import DataView from '../../../src/components/DataView/DataView.vue';
  import DataViewFilters from '../../../src/components/DataViewFilters/DataViewFilters.vue';
  import DataViewSortButton from '../../../src/components/DataViewSortButton/DataViewSortButton.vue';
  import DataViewToolbar from '../../../src/components/DataViewToolbar/DataViewToolbar.vue';
  import Module from '../../../src/components/Module/Module.vue';
  import ModuleContent from '../../../src/components/ModuleContent/ModuleContent.vue';
  import ModuleHeader from '../../../src/components/ModuleHeader/ModuleHeader.vue';
  import Table from '../../../src/components/Table/Table.vue';
  import TableCell from '../../../src/components/TableCell/TableCell.vue';
  import TableHeaderCell from '../../../src/components/TableHeaderCell/TableHeaderCell.vue';
  import TableRow from '../../../src/components/TableRow/TableRow.vue';
  import { money } from '../../../src/utils/i18n';

  const PAGE_SIZE = 4;

  const sortOptions = [
    { id: 'product_name', labelAsc: 'Product Name (A-Z)', labelDesc: 'Product Name (Z-A)' },
    { id: 'category_name', labelAsc: 'Category Name (A-Z)', labelDesc: 'Category Name (Z-A)' },
    {
      id: 'price_per_unit_amount',
      labelAsc: 'Price Per Unit (Low to High)',
      labelDesc: 'Price Per Unit (High to Low)',
    },
    { id: 'strain_classification', labelAsc: 'Strain (A-Z)', labelDesc: 'Strain (Z-A)' },
  ];

  const dataViewRef = ref<InstanceType<typeof DataView>>();
  const products = ref<Product[]>([]);
  const isLoadingProducts = ref(false);
  const totalProductCount = ref(products.value.length);

  async function fetchProducts() {
    if (!dataViewRef.value || isLoadingProducts.value) {
      return;
    }

    try {
      isLoadingProducts.value = true;

      const response = await apiGetProducts({
        params: {
          page: dataViewRef.value.page || 1,
          pageSize: dataViewRef.value.pageSize || PAGE_SIZE,
          ordering: dataViewRef.value.ordering,
          search: dataViewRef.value.search,
        },
      });

      products.value = response.results;
      totalProductCount.value = response.count;
    } finally {
      isLoadingProducts.value = false;
    }
  }

  onMounted(() => {
    fetchProducts();
  });
</script>

<template>
  <Module variant="table">
    <ModuleHeader title="Some Data Table" description="We're rendering a DataView Table within a Module" />
    <ModuleContent>
      <DataView
        ref="dataViewRef"
        :data="products"
        :page-size="PAGE_SIZE"
        :total-data-count="totalProductCount"
        @update="fetchProducts()"
      >
        <DataViewFilters :search-bar-props="{ hintText: 'Search for Product Name', placeholder: 'Ex: Citrus' }" />
        <DataViewToolbar>
          <DataViewSortButton :sort-options="sortOptions" />
        </DataViewToolbar>
        <Table :is-empty="products.length === 0" :is-loading="isLoadingProducts">
          <template #head>
            <tr>
              <TableHeaderCell class="tw-min-w-[300px]" sort-id="product_name">Product Name</TableHeaderCell>
              <TableHeaderCell class="tw-min-w-[140px]">Brand</TableHeaderCell>
              <TableHeaderCell sort-id="category_name">Category</TableHeaderCell>
              <TableHeaderCell sort-id="price_per_unit_amount">Price per unit</TableHeaderCell>
            </tr>
          </template>
          <template #body>
            <TableRow v-for="product in products" :key="product.id">
              <TableCell>
                <div class="tw-flex tw-items-center">
                  <img :src="product.featured_image" width="60" class="tw-mr-3 tw-rounded" />
                  {{ product.name }}
                </div>
              </TableCell>
              <TableCell>{{ product.brand.name }}</TableCell>
              <TableCell>{{ product.category.name }}</TableCell>
              <TableCell>{{ money(product.price_per_unit) }}</TableCell>
            </TableRow>
          </template>
        </Table>
      </DataView>
    </ModuleContent>
  </Module>
</template>

With Tabs

The Tabs component can be used to display multiple views of the data.

Product Name
Brand
Category
Price per unit
Strain
No results
vue
<script setup lang="ts">
  import { onMounted, ref } from 'vue';

  import {
    brandOptions,
    fetchProducts as apiGetProducts,
    Product,
    strainOptions,
  } from '../../../src/components/DataView/DataView.fixtures';
  import DataView from '../../../src/components/DataView/DataView.vue';
  import DataViewFilters, {
    useFilters,
    UseFiltersSchema,
  } from '../../../src/components/DataViewFilters/DataViewFilters.vue';
  import DataViewSortButton from '../../../src/components/DataViewSortButton/DataViewSortButton.vue';
  import DataViewToolbar from '../../../src/components/DataViewToolbar/DataViewToolbar.vue';
  import FilterDrawerItem from '../../../src/components/FilterDrawerItem/FilterDrawerItem.vue';
  import FilterDropdown from '../../../src/components/FilterDropdown/FilterDropdown.vue';
  import Input from '../../../src/components/Input/Input.vue';
  import Select from '../../../src/components/Select/Select.vue';
  import Tab from '../../../src/components/Tab/Tab.vue';
  import Table from '../../../src/components/Table/Table.vue';
  import TableCell from '../../../src/components/TableCell/TableCell.vue';
  import TableHeaderCell from '../../../src/components/TableHeaderCell/TableHeaderCell.vue';
  import TableRow from '../../../src/components/TableRow/TableRow.vue';
  import Tabs from '../../../src/components/Tabs/Tabs.vue';
  import { money } from '../../../src/utils/i18n';

  const PAGE_SIZE = 4;

  const sortOptions = [
    { id: 'product_name', labelAsc: 'Product Name (A-Z)', labelDesc: 'Product Name (Z-A)' },
    { id: 'category_name', labelAsc: 'Category Name (A-Z)', labelDesc: 'Category Name (Z-A)' },
    {
      id: 'price_per_unit_amount',
      labelAsc: 'Price Per Unit (Low to High)',
      labelDesc: 'Price Per Unit (High to Low)',
    },
    { id: 'strain_classification', labelAsc: 'Strain (A-Z)', labelDesc: 'Strain (Z-A)' },
  ];
  const dataViewRef = ref<InstanceType<typeof DataView>>();
  const products = ref<Product[]>([]);
  const isLoadingProducts = ref(false);
  const totalProductCount = ref(products.value.length);

  // #region Filters
  interface FilterValues {
    strain?: (typeof strainOptions)[number];
    brand?: (typeof brandOptions)[number];
    priceMin?: string | number;
    priceMax?: string | number;
  }

  type FilterGroups = 'type' | 'price';

  type FiltersDrawerStep = 'base' | FilterGroups;
  const filtersDrawerStep = ref<FiltersDrawerStep>('base');

  const filterSchema: UseFiltersSchema<FilterValues, FilterGroups> = {
    strain: {
      group: 'type',
    },
    brand: {
      group: 'type',
    },
    priceMin: {
      group: 'price',
    },
    priceMax: {
      group: 'price',
    },
  };

  const useFiltersInstance = useFilters({ schema: filterSchema, dataViewRef });

  const { workingFilters, appliedFilters } = useFiltersInstance;
  // #endregion

  async function fetchProducts() {
    if (!dataViewRef.value || isLoadingProducts.value) {
      return;
    }

    try {
      isLoadingProducts.value = true;

      const response = await apiGetProducts({
        params: {
          page: dataViewRef.value.page || 1,
          pageSize: dataViewRef.value.pageSize || PAGE_SIZE,
          ordering: dataViewRef.value.ordering,
          search: dataViewRef.value.search,
          strain: appliedFilters.value.strain?.id,
          brandId: appliedFilters.value.brand?.id,
          priceMin: appliedFilters.value.priceMin,
          priceMax: appliedFilters.value.priceMax,
        },
      });

      products.value = response.results;
      totalProductCount.value = response.count;
    } finally {
      isLoadingProducts.value = false;
    }
  }

  const activeTab = ref('overview');

  onMounted(() => {
    fetchProducts();
  });
</script>

<template>
  <DataView
    ref="dataViewRef"
    :data="products"
    :page-size="PAGE_SIZE"
    :total-data-count="totalProductCount"
    variant="table"
    :is-loading="isLoadingProducts"
    :is-empty="products.length === 0"
    @update="fetchProducts()"
  >
    <DataViewFilters
      :use-filters-instance="useFiltersInstance"
      :active-group="filtersDrawerStep"
      :search-bar-props="{
        hintText: 'Search for Product Name',
        placeholder: 'Ex: Citrus',
      }"
      :show-drawer-previous-button="filtersDrawerStep !== 'base'"
      @previous="filtersDrawerStep = 'base'"
      @open-drawer="filtersDrawerStep = 'base'"
    >
      <FilterDropdown label="Type" group="type">
        <Select v-model="workingFilters.strain" label="Strain" single add-bottom-space :options="strainOptions" />
        <Select v-model="workingFilters.brand" label="Brand name" single add-bottom-space :options="brandOptions" />
      </FilterDropdown>
      <FilterDropdown label="Price" group="price">
        <Input v-model="workingFilters.priceMin" label="Minimum Price" type="number" add-bottom-space />
        <Input v-model="workingFilters.priceMax" label="Maximum Price" type="number" add-bottom-space />
      </FilterDropdown>
      <template #drawer>
        <div class="tw-p-4">
          <template v-if="filtersDrawerStep === 'base'">
            <FilterDrawerItem title="Type" group="type" @navigate="filtersDrawerStep = 'type'" />
            <FilterDrawerItem title="Price" group="price" @navigate="filtersDrawerStep = 'price'" />
          </template>
          <template v-else-if="filtersDrawerStep === 'type'">
            <Select v-model="workingFilters.strain" label="Strain" single add-bottom-space :options="strainOptions" />
            <Select v-model="workingFilters.brand" label="Brand name" single add-bottom-space :options="brandOptions" />
          </template>
          <template v-else-if="filtersDrawerStep === 'price'">
            <Input v-model="workingFilters.priceMin" label="Minimum Price" type="number" add-bottom-space />
            <Input v-model="workingFilters.priceMax" label="Maximum Price" type="number" add-bottom-space />
          </template>
        </div>
      </template>
    </DataViewFilters>

    <Tabs v-model:active-tab="activeTab" variant="enclosed">
      <Tab value="overview">Overview</Tab>
      <Tab value="payment-and-transfers">Payment & Transfers</Tab>
      <Tab value="transactions">Transactions</Tab>
    </Tabs>
    <div id="tabpanel-overview" role="tabpanel" :hidden="activeTab !== 'overview'">
      <DataViewToolbar has-tabs-above>
        <DataViewSortButton :sort-options="sortOptions" />
      </DataViewToolbar>

      <Table>
        <template #head>
          <tr>
            <TableHeaderCell class="tw-min-w-[300px]" sort-id="product_name">Product Name</TableHeaderCell>
            <TableHeaderCell class="tw-min-w-[140px]">Brand</TableHeaderCell>
            <TableHeaderCell sort-id="category_name">Category</TableHeaderCell>
            <TableHeaderCell sort-id="price_per_unit_amount">Price per unit</TableHeaderCell>
            <TableHeaderCell sort-id="strain_classification">Strain</TableHeaderCell>
          </tr>
        </template>
        <template #body>
          <TableRow v-for="product in products" :key="product.id">
            <TableCell>
              <div class="tw-flex tw-items-center">
                <img :src="product.featured_image" width="60" class="tw-mr-3 tw-rounded" />
                {{ product.name }}
              </div>
            </TableCell>
            <TableCell>
              {{ product.brand.name }}
            </TableCell>
            <TableCell>
              {{ product.category.name }}
            </TableCell>
            <TableCell>
              {{ money(product.price_per_unit) }}
            </TableCell>
            <TableCell class="show-empty">
              {{ product.strain_classification }}
            </TableCell>
          </TableRow>
        </template>
      </Table>
    </div>
    <div id="tabpanel-payment-and-transfer" class="tw-bg-white tw-p-4" :hidden="activeTab !== 'payment-and-transfers'">
      Payment & Transfers
    </div>
    <div id="tabpanel-transactions" class="tw-bg-white tw-p-4" :hidden="activeTab !== 'transactions'">Transactions</div>
  </DataView>
</template>

API

See the documentation below for a complete reference to all the props and classes available to the components mentioned here.